feat(wasmguest): guest capability stubs over host calls (WO-WZ-003)

Every CoreServices interface (core/plugin/deps.go) now has a guest-side stub
that marshals to the WO-WZ-001 capability messages and dispatches through the
generic host_call transport, so plugin service code compiles and runs
unchanged against content.Content, settings.Settings, plugin.PluginBridge, etc.

- core/plugin/wasmguest/caps/: one file per family (17 families, 38 methods),
  a var _ <iface> = (*stub)(nil) compile proof each, and NewCoreServices(call)
  assembling them. The transport is injected (CallFunc) so marshaling is
  natively testable; the wasm shim binds it to CallHost, DESCRIBE probes pass
  nil (capability calls fail cleanly instead of nil-panicking).
- Error mapping wraps AbiError with <family>.<method> context and maps
  DEADLINE_EXCEEDED onto context.DeadlineExceeded.
- RAGService.RegisterContentFetcher stays guest-side (RAGStub) for
  HOOK_RAG_FETCH dispatch; Query/OnContentChanged marshal out. dispatch.go and
  describe.go now source fetchers from the caps RAG stub.
- caps_roundtrip_test.go: fake transport + 76 deterministic golden payloads
  (family_method_{req,resp}.pb) covering 100% of families, plus error-mapping,
  deadline, nil-transport, and guest-side-fetcher tests. WO-WZ-006 replays the
  same goldens to prevent host/guest drift.
- Acceptance: testdata/fixture Load hook calls deps.Content/Settings/Bridge
  unchanged (compiles for wasip1); caps_wasmhost_test.go drives it end-to-end
  through a real wazero module + fake host_call table.
- Disposition table in docs/wasm-abi.md: every member stub | host-side
  (Pool, Interceptors, AppURL/MediaPath, CoreServiceBindings host-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-03 14:23:58 +08:00
parent 5f4fa9db0f
commit bec3a43f55
104 changed files with 2058 additions and 44 deletions

View File

@ -97,6 +97,16 @@ A `packed` value of `0` means the callee could not even produce an envelope
(allocation failure / trap); the caller treats it as
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
> A logically-empty response is **not** packed `0`. A successful hook whose
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
> `len==0 → return 0` shortcut made every successful empty-response hook —
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
> instance. The cms host in WO-WZ-006 must likewise not conflate a
> zero-length payload with a missing envelope.)
Instances are single-threaded: one `bn_invoke` at a time per instance;
concurrency comes from the per-plugin instance pool.
@ -146,7 +156,52 @@ each pair mirrors one Go interface method from `CoreServices` 1:1
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
The guest half of the SDK implements the existing Go interfaces as stubs
marshaling to these calls, so plugin code compiles unchanged.
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
the wasm shim binds `call` to the real `host_call` transport, tests inject a
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
instead of nil-panicking.
### Method disposition (every `CoreServices` member)
No silent gaps: each member is either a guest stub or served host-side.
| Member | Disposition |
|---|---|
| `Content` (7 methods) | **stub**`caps/content.go` |
| `Settings` / `SettingsUpdater` | **stub**`caps/settings.go` (one value, both fields) |
| `Gating` | **stub**`caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
| `Crypto` | **stub**`caps/crypto.go` |
| `Menus` | **stub**`caps/menus.go` |
| `Datasources` | **stub**`caps/datasources.go` |
| `PublicUsers` | **stub**`caps/users.go` |
| `Subscriptions` | **stub**`caps/subscriptions.go` |
| `Media` | **stub**`caps/media.go` |
| `ToolRegistry` + `AITextCall` | **stub**`caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
| `EmailSender` | **stub**`caps/email.go` |
| `Bridge` | **stub**`caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
| `ReviewSubmitter` | **stub**`caps/reviews.go` |
| `BadgeRefresher` | **stub**`caps/badges.go` |
| `JobRunner` | **stub**`caps/jobs.go` |
| `EmbeddingService` | **stub**`caps/embeddings.go` |
| `RAGService` | **stub**`caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. Auth context reaches the guest via `HttpRequest` headers (host-side interceptors already ran). |
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
Interface satisfaction is proven at compile time by a `var _ <iface> =
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
crosses the ABI for real.
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
zero value / best-effort on transport failure.
`CoreServices` members that do **not** cross as capability calls:

View File

@ -0,0 +1,52 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
)
// aiStub implements ai.ToolRegistry (ai.tools.register) and backs the
// CoreServices.AITextCall func field (ai.text_call).
//
// ToolDefinition.Handler stays guest-side: registration marshals only the
// static descriptor. Host→guest tool execution is a runtime-WO concern
// flagged in core/docs/wasm-abi.md.
type aiStub struct{ base }
var _ ai.ToolRegistry = (*aiStub)(nil)
// Register has no error channel; a transport failure is dropped (the host
// records nothing, matching the "best effort at load" contract).
func (s *aiStub) Register(tool *ai.ToolDefinition) {
if tool == nil {
return
}
req := &abiv1.AiToolRegisterRequest{
Slug: tool.Slug,
Name: tool.Name,
Description: tool.Description,
}
if tool.ParameterSchema != nil {
if raw, err := json.Marshal(tool.ParameterSchema); err == nil {
req.ParameterSchemaJson = raw
}
}
_ = s.invoke(context.Background(), "tools.register", req, &abiv1.AiToolRegisterResponse{})
}
// textCall backs CoreServices.AITextCall.
func (s *aiStub) textCall(ctx context.Context, taskKey, systemPrompt, userMessage string) (string, error) {
req := &abiv1.AiTextCallRequest{
TaskKey: taskKey,
SystemPrompt: systemPrompt,
UserMessage: userMessage,
}
resp := &abiv1.AiTextCallResponse{}
if err := s.invoke(ctx, "text_call", req, resp); err != nil {
return "", err
}
return resp.GetText(), nil
}

View File

@ -0,0 +1,20 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
)
// badgesStub implements plugin.BadgeRefresher over the badges.refresh_badges
// capability call.
type badgesStub struct{ base }
var _ plugin.BadgeRefresher = (*badgesStub)(nil)
func (s *badgesStub) RefreshBadges(ctx context.Context, tableID, rowID uuid.UUID) error {
req := &abiv1.BadgesRefreshBadgesRequest{TableId: tableID.String(), RowId: rowID.String()}
return s.invoke(ctx, "refresh_badges", req, &abiv1.BadgesRefreshBadgesResponse{})
}

View File

@ -0,0 +1,35 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
)
// bridgeStub implements plugin.PluginBridge over bridge.* capability calls.
//
// The bridge shares in-process Go values today; across sandboxes only the
// registration/lookup *surface* serializes — the service value itself cannot
// cross. RegisterService therefore forwards only plugin/service names (the
// value is dropped), and GetService returns nil (availability is checked but a
// typed value cannot be reconstructed guest-side). Typed cross-plugin
// invocation is an open ABI item (core/docs/wasm-abi.md).
type bridgeStub struct{ base }
var _ plugin.PluginBridge = (*bridgeStub)(nil)
// RegisterService has no error channel; a transport failure is dropped.
func (s *bridgeStub) RegisterService(pluginName, serviceName string, _ any) {
req := &abiv1.BridgeRegisterServiceRequest{PluginName: pluginName, ServiceName: serviceName}
_ = s.invoke(context.Background(), "register_service", req, &abiv1.BridgeRegisterServiceResponse{})
}
// GetService reports availability host-side but cannot return the concrete Go
// value across the sandbox boundary, so it always returns nil. Callers using
// plugin.GetServiceAs correctly observe (zero, false).
func (s *bridgeStub) GetService(pluginName, serviceName string) any {
req := &abiv1.BridgeGetServiceRequest{PluginName: pluginName, ServiceName: serviceName}
_ = s.invoke(context.Background(), "get_service", req, &abiv1.BridgeGetServiceResponse{})
return nil
}

View File

@ -0,0 +1,89 @@
// Package caps implements every CoreServices capability interface
// (core/plugin/deps.go) as a guest-side stub that marshals to the WO-WZ-001
// capability messages (abiv1) and dispatches them through a single generic
// guest→host transport. Plugin code keeps compiling and calling against
// content.Content, settings.Settings, plugin.PluginBridge, etc. — unchanged —
// while the concrete work now happens host-side over the wasm ABI.
//
// # The transport seam
//
// A capability call is "marshal a family request, invoke
// '<family>.<method>', unmarshal the reply". That transport is injected as a
// CallFunc so the marshaling logic in this package stays natively testable
// (a fake CallFunc in caps_roundtrip_test.go), while the wasm guest shim
// (package wasmguest, wasip1) binds the real host_call-backed transport. This
// package deliberately does NOT import wasmguest: wasmguest imports caps (to
// assemble the services and reach the guest-side RAG fetcher registry), so the
// dependency only points one way.
//
// # Method disposition
//
// Every CoreServices member is either a stub here or documented host-side in
// core/docs/wasm-abi.md §"Capability calls". Host-side members (no stub):
// Pool (db.* driver), Interceptors (host connect options), AppURL/MediaPath
// (delivered in LoadRequest.host_config), and CoreServiceBindings (static
// manifest.core_service_bindings the host mounts). RAGService.
// RegisterContentFetcher is guest-side (records fetchers for HOOK_RAG_FETCH);
// its Query/OnContentChanged marshal out.
package caps
import (
"context"
"errors"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"google.golang.org/protobuf/proto"
)
// CallFunc is the guest→host capability transport: marshal req, invoke the
// host with method "<family>.<method>", and unmarshal the reply into resp. It
// matches wasmguest.CallHost exactly so the wasip1 shim can bind it directly.
// A nil CallFunc (native builds, DESCRIBE probes) makes every capability call
// fail cleanly with errNoHost rather than panic.
type CallFunc func(method string, req, resp proto.Message) error
// abiCoded is implemented by transport errors that carry an ABI error code
// (wasmguest.HostError does). It lets the stubs map a DEADLINE_EXCEEDED reply
// onto context.DeadlineExceeded without importing wasmguest.
type abiCoded interface {
AbiErrorCode() abiv1.AbiErrorCode
}
// base is embedded by every family stub: the family name and the transport.
type base struct {
family string
call CallFunc
}
// invoke performs one capability call and maps any transport error with
// family/method context. ctx is honored up front — an already-cancelled or
// expired context short-circuits before crossing the boundary — but is not
// forwarded to the transport itself (the host enforces the invoke deadline
// carried in InvokeRequest.deadline_ms; see core/docs/wasm-abi.md).
func (b base) invoke(ctx context.Context, method string, req, resp proto.Message) error {
if b.call == nil {
return fmt.Errorf("%s.%s: no host transport bound (native build)", b.family, method)
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return fmt.Errorf("%s.%s: %w", b.family, method, err)
}
}
return b.mapErr(method, b.call(b.family+"."+method, req, resp))
}
// mapErr wraps a transport error with family/method context so plugin logs
// stay legible, and translates an ABI deadline into context.DeadlineExceeded
// so callers' errors.Is(err, context.DeadlineExceeded) keeps working.
func (b base) mapErr(method string, err error) error {
if err == nil {
return nil
}
var coded abiCoded
if errors.As(err, &coded) &&
coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_DEADLINE_EXCEEDED {
return fmt.Errorf("%s.%s: %w: %v", b.family, method, context.DeadlineExceeded, err)
}
return fmt.Errorf("%s.%s: %w", b.family, method, err)
}

View File

@ -0,0 +1,677 @@
package caps
import (
"context"
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
"git.dev.alexdunmow.com/block/core/gating"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
// -update regenerates the golden capability payloads. WO-WZ-006 (cms host)
// replays these SAME goldens to prove the host and guest agree on the wire, so
// they must stay deterministic (proto marshaled with Deterministic=true).
var update = flag.Bool("update", false, "regenerate golden capability payloads")
const goldenDir = "testdata/golden"
// Fixed IDs keep the goldens stable across runs.
var (
idUser = uuid.MustParse("11111111-1111-1111-1111-111111111111")
idAuthor = uuid.MustParse("22222222-2222-2222-2222-222222222222")
idMenu = uuid.MustParse("33333333-3333-3333-3333-333333333333")
idItem = uuid.MustParse("44444444-4444-4444-4444-444444444444")
idParent = uuid.MustParse("55555555-5555-5555-5555-555555555555")
idBucket = uuid.MustParse("66666666-6666-6666-6666-666666666666")
idTier = uuid.MustParse("77777777-7777-7777-7777-777777777777")
idPlan = uuid.MustParse("88888888-8888-8888-8888-888888888888")
idMedia = uuid.MustParse("99999999-9999-9999-9999-999999999999")
idTable = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
idRow = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
planTime = time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
)
func goldenPath(name string) string { return filepath.Join(goldenDir, name+".pb") }
func detMarshal(t *testing.T, m proto.Message) []byte {
t.Helper()
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// fakeTransport is the swapped-in CallFunc: it checks the request the stub
// produced against a golden, then feeds back a canned response golden.
type fakeTransport struct {
t *testing.T
name string // golden basename for the current call
cannedResp proto.Message // synthesizes the resp golden under -update
err error // returned instead of a response (error-mapping cases)
gotMethod string
}
func (f *fakeTransport) call(method string, req, resp proto.Message) error {
f.gotMethod = method
reqBytes := detMarshal(f.t, req)
reqPath := goldenPath(f.name + "_req")
if *update {
writeGolden(f.t, reqPath, reqBytes)
} else {
want, err := os.ReadFile(reqPath)
if err != nil {
f.t.Fatalf("%s: read request golden (run -update?): %v", f.name, err)
}
if !equalProto(f.t, want, reqBytes, req) {
f.t.Errorf("%s: request payload drifted from golden %s", f.name, reqPath)
}
}
if f.err != nil {
return f.err
}
respPath := goldenPath(f.name + "_resp")
if *update {
respBytes := detMarshal(f.t, f.cannedResp)
writeGolden(f.t, respPath, respBytes)
if err := proto.Unmarshal(respBytes, resp); err != nil {
f.t.Fatalf("%s: unmarshal canned resp: %v", f.name, err)
}
return nil
}
respBytes, err := os.ReadFile(respPath)
if err != nil {
f.t.Fatalf("%s: read response golden (run -update?): %v", f.name, err)
}
if err := proto.Unmarshal(respBytes, resp); err != nil {
f.t.Fatalf("%s: unmarshal response golden: %v", f.name, err)
}
return nil
}
func writeGolden(t *testing.T, path string, b []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir golden: %v", err)
}
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
// equalProto compares two serializations by decoding both into fresh messages
// of the same type, so semantically-equal encodings still match.
func equalProto(t *testing.T, want, got []byte, sample proto.Message) bool {
t.Helper()
a := sample.ProtoReflect().New().Interface()
b := sample.ProtoReflect().New().Interface()
if err := proto.Unmarshal(want, a); err != nil {
t.Fatalf("unmarshal want: %v", err)
}
if err := proto.Unmarshal(got, b); err != nil {
t.Fatalf("unmarshal got: %v", err)
}
return proto.Equal(a, b)
}
// capCase is one capability round trip: set up the fake, call the stub through
// the assembled CoreServices, and assert both the wire method and the decoded
// Go result.
type capCase struct {
name string
wantMethod string
resp proto.Message
run func(t *testing.T, cs plugin.CoreServices)
}
func TestCapabilityRoundTrip(t *testing.T) {
ctx := context.Background()
f := &fakeTransport{t: t}
cs := NewCoreServices(f.call)
cases := []capCase{
// --- content ---
{
name: "content_get_author_profile", wantMethod: "content.get_author_profile",
resp: &abiv1.ContentGetAuthorProfileResponse{Author: &abiv1.AuthorProfile{
Id: idAuthor.String(), Name: "Ada", Slug: "ada", Bio: "hi",
AvatarUrl: "https://x/a.png", Website: "https://ada.dev",
SocialLinks: map[string]string{"x": "@ada", "gh": "ada"},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetAuthorProfile(ctx, idAuthor)
if err != nil {
t.Fatal(err)
}
if got.ID != idAuthor || got.Name != "Ada" || got.SocialLinks["x"] != "@ada" {
t.Errorf("author = %+v", got)
}
},
},
{
name: "content_get_page", wantMethod: "content.get_page",
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetPage(ctx, "about")
if err != nil {
t.Fatal(err)
}
if got.Slug != "about" || got.Title != "About Us" {
t.Errorf("page = %+v", got)
}
},
},
{
name: "content_get_post", wantMethod: "content.get_post",
resp: &abiv1.ContentGetPostResponse{Post: &abiv1.PostInfo{
Id: idAuthor.String(), Slug: "hello", Title: "Hello", Excerpt: "hi",
FeaturedImageUrl: "media:x", AuthorId: idAuthor.String(),
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetPost(ctx, "hello")
if err != nil {
t.Fatal(err)
}
if got.Title != "Hello" || got.AuthorID != idAuthor {
t.Errorf("post = %+v", got)
}
},
},
{
name: "content_slugify", wantMethod: "content.slugify",
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.Slugify("Hello World"); got != "hello-world" {
t.Errorf("slug = %q", got)
}
},
},
{
name: "content_block_note_to_html", wantMethod: "content.block_note_to_html",
resp: &abiv1.ContentBlockNoteToHtmlResponse{Html: "<p>hi</p>"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.BlockNoteToHTML(ctx, map[string]any{"type": "doc"}); got != "<p>hi</p>" {
t.Errorf("html = %q", got)
}
},
},
{
name: "content_generate_excerpt", wantMethod: "content.generate_excerpt",
resp: &abiv1.ContentGenerateExcerptResponse{Excerpt: "short"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.GenerateExcerpt("<p>long text</p>", 5); got != "short" {
t.Errorf("excerpt = %q", got)
}
},
},
{
name: "content_strip_html", wantMethod: "content.strip_html",
resp: &abiv1.ContentStripHtmlResponse{Text: "plain"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.StripHTML("<b>plain</b>"); got != "plain" {
t.Errorf("text = %q", got)
}
},
},
// --- settings ---
{
name: "settings_get_site_settings", wantMethod: "settings.get_site_settings",
resp: &abiv1.SettingsGetSiteSettingsResponse{SettingsJson: []byte(`{"site_name":"Fixture","theme":"dark"}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
m, err := cs.Settings.GetSiteSettings(ctx)
if err != nil {
t.Fatal(err)
}
if m["site_name"] != "Fixture" {
t.Errorf("settings = %+v", m)
}
},
},
{
name: "settings_get_plugin_settings", wantMethod: "settings.get_plugin_settings",
resp: &abiv1.SettingsGetPluginSettingsResponse{SettingsJson: []byte(`{"enabled":true}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
m, err := cs.Settings.GetPluginSettings(ctx, "symposium")
if err != nil {
t.Fatal(err)
}
if m["enabled"] != true {
t.Errorf("plugin settings = %+v", m)
}
},
},
{
name: "settings_update_site_setting", wantMethod: "settings.update_site_setting",
resp: &abiv1.SettingsUpdateSiteSettingResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.SettingsUpdater.UpdateSiteSetting(ctx, "theme", "light"); err != nil {
t.Fatal(err)
}
},
},
// --- gating ---
{
name: "gating_get_subscriber_tier_level", wantMethod: "gating.get_subscriber_tier_level",
resp: &abiv1.GatingGetSubscriberTierLevelResponse{Level: 3},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Gating.GetSubscriberTierLevel(ctx, idUser)
if err != nil {
t.Fatal(err)
}
if got != 3 {
t.Errorf("level = %d", got)
}
},
},
{
name: "gating_evaluate_access", wantMethod: "gating.evaluate_access",
resp: &abiv1.GatingEvaluateAccessResponse{Result: &abiv1.AccessResult{
HasAccess: false, TeaserMode: "soft", TeaserPercent: 20, RequiredLevel: 2,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got := cs.Gating.EvaluateAccess(1, &gating.AccessRule{MinTierLevel: 2, TeaserMode: "soft", TeaserPercent: 20})
if got.HasAccess || got.TeaserMode != "soft" || got.RequiredLevel != 2 {
t.Errorf("access = %+v", got)
}
},
},
// --- crypto ---
{
name: "crypto_encrypt_secret", wantMethod: "crypto.encrypt_secret",
resp: &abiv1.CryptoEncryptSecretResponse{Ciphertext: "enc:abc"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Crypto.EncryptSecret("plain")
if err != nil || got != "enc:abc" {
t.Errorf("ciphertext = %q err = %v", got, err)
}
},
},
{
name: "crypto_decrypt_secret", wantMethod: "crypto.decrypt_secret",
resp: &abiv1.CryptoDecryptSecretResponse{Plaintext: "plain"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Crypto.DecryptSecret("enc:abc")
if err != nil || got != "plain" {
t.Errorf("plaintext = %q err = %v", got, err)
}
},
},
// --- menus ---
{
name: "menus_get_menu_by_name", wantMethod: "menus.get_menu_by_name",
resp: &abiv1.MenusGetMenuByNameResponse{Menu: &abiv1.Menu{Id: idMenu.String(), Name: "main"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Menus.GetMenuByName(ctx, "main")
if err != nil || got.ID != idMenu || got.Name != "main" {
t.Errorf("menu = %+v err = %v", got, err)
}
},
},
{
name: "menus_get_menu_items", wantMethod: "menus.get_menu_items",
resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{
Id: idItem.String(), MenuId: idMenu.String(), Label: "Home", Url: "/",
PageSlug: "home", ParentId: proto.String(idParent.String()), SortOrder: 1,
OpenInNewTab: true, CssClass: "nav", ItemType: "link", Icon: "home",
}}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Menus.GetMenuItems(ctx, idMenu)
if err != nil || len(got) != 1 {
t.Fatalf("items = %+v err = %v", got, err)
}
it := got[0]
if it.ID != idItem || it.ParentID == nil || *it.ParentID != idParent || !it.OpenInNewTab {
t.Errorf("item = %+v", it)
}
},
},
// --- datasources ---
{
name: "datasources_resolve_bucket", wantMethod: "datasources.resolve_bucket",
resp: &abiv1.DatasourcesResolveBucketResponse{Result: &abiv1.DatasourceResult{
ItemsJson: []byte(`[{"id":1},{"id":2}]`), Total: 2, MetaJson: []byte(`{"page":1}`),
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Datasources.ResolveBucket(ctx, idBucket)
if err != nil || got.Total != 2 || len(got.Items) != 2 || got.Meta["page"] != float64(1) {
t.Errorf("result = %+v err = %v", got, err)
}
},
},
{
name: "datasources_resolve_bucket_by_key", wantMethod: "datasources.resolve_bucket_by_key",
resp: &abiv1.DatasourcesResolveBucketByKeyResponse{Result: &abiv1.DatasourceResult{
ItemsJson: []byte(`[]`), Total: 0,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Datasources.ResolveBucketByKey(ctx, "featured")
if err != nil || got.Total != 0 {
t.Errorf("result = %+v err = %v", got, err)
}
},
},
// --- users ---
{
name: "users_get_by_username", wantMethod: "users.get_by_username",
resp: &abiv1.UsersGetByUsernameResponse{User: &abiv1.PublicUserProfile{
Id: idUser.String(), Email: "u@x.com", Username: "u", DisplayName: "U",
AvatarUrl: "a", Bio: "b", EmailVerified: true, Role: "member",
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.PublicUsers.GetByUsername(ctx, "u")
if err != nil || got.ID != idUser || !got.EmailVerified {
t.Errorf("user = %+v err = %v", got, err)
}
},
},
{
name: "users_get_by_id", wantMethod: "users.get_by_id",
resp: &abiv1.UsersGetByIdResponse{User: &abiv1.PublicUserProfile{Id: idUser.String(), Username: "u"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.PublicUsers.GetByID(ctx, idUser)
if err != nil || got.Username != "u" {
t.Errorf("user = %+v err = %v", got, err)
}
},
},
// --- subscriptions ---
{
name: "subscriptions_get_user_tier_level", wantMethod: "subscriptions.get_user_tier_level",
resp: &abiv1.SubscriptionsGetUserTierLevelResponse{TierLevel: &abiv1.TierLevel{Level: 5, Features: []byte(`{"a":1}`)}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.GetUserTierLevel(ctx, idUser)
if err != nil || got.Level != 5 || string(got.Features) != `{"a":1}` {
t.Errorf("tier level = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_get_tier_by_slug", wantMethod: "subscriptions.get_tier_by_slug",
resp: &abiv1.SubscriptionsGetTierBySlugResponse{Tier: &abiv1.Tier{
Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3, Description: "d",
Features: []byte(`{}`), IsDefault: true, Position: 2,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.GetTierBySlug(ctx, "gold")
if err != nil || got.ID != idTier || got.Level != 3 || !got.IsDefault {
t.Errorf("tier = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_list_tiers", wantMethod: "subscriptions.list_tiers",
resp: &abiv1.SubscriptionsListTiersResponse{Tiers: []*abiv1.Tier{
{Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.ListTiers(ctx)
if err != nil || len(got) != 1 || got[0].Slug != "gold" {
t.Errorf("tiers = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_list_active_plans", wantMethod: "subscriptions.list_active_plans",
resp: &abiv1.SubscriptionsListActivePlansResponse{Plans: []*abiv1.Plan{{
Id: idPlan.String(), TierId: idTier.String(), BillingInterval: "month",
Amount: 4900, Currency: "usd", IsActive: true, CreatedAt: timestamppb.New(planTime),
}}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.ListActivePlans(ctx, idTier)
if err != nil || len(got) != 1 {
t.Fatalf("plans = %+v err = %v", got, err)
}
p := got[0]
if p.ID != idPlan || p.Amount != 4900 || !p.CreatedAt.Equal(planTime) {
t.Errorf("plan = %+v", p)
}
},
},
// --- media ---
{
name: "media_deposit", wantMethod: "media.deposit",
resp: &abiv1.MediaDepositResponse{Id: idMedia.String(), Ref: "media:" + idMedia.String(), Created: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Media.Deposit(ctx, plugin.MediaDeposit{
ID: idMedia, Filename: "hero.jpg", Data: []byte("bytes"), AltText: "alt", Folder: "f", Source: "plugin",
})
if err != nil || got.ID != idMedia || !got.Created {
t.Errorf("media = %+v err = %v", got, err)
}
},
},
// --- email ---
{
name: "email_send", wantMethod: "email.send",
resp: &abiv1.EmailSendResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.EmailSender.Send("to@x.com", "Subj", "Body"); err != nil {
t.Fatal(err)
}
},
},
// --- ai ---
{
name: "ai_text_call", wantMethod: "ai.text_call",
resp: &abiv1.AiTextCallResponse{Text: "generated"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.AITextCall(ctx, "summarize", "sys", "user")
if err != nil || got != "generated" {
t.Errorf("text = %q err = %v", got, err)
}
},
},
{
name: "ai_tools_register", wantMethod: "ai.tools.register",
resp: &abiv1.AiToolRegisterResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.ToolRegistry.Register(&ai.ToolDefinition{
Slug: "lookup", Name: "Lookup", Description: "d",
ParameterSchema: map[string]any{"type": "object"},
})
},
},
// --- bridge ---
{
name: "bridge_register_service", wantMethod: "bridge.register_service",
resp: &abiv1.BridgeRegisterServiceResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.Bridge.RegisterService("symposium", "search", struct{}{})
},
},
{
name: "bridge_get_service", wantMethod: "bridge.get_service",
resp: &abiv1.BridgeGetServiceResponse{Available: true},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Bridge.GetService("symposium", "search"); got != nil {
t.Errorf("get_service crossed a value: %v (want nil)", got)
}
},
},
// --- jobs ---
{
name: "jobs_submit", wantMethod: "jobs.submit",
resp: &abiv1.JobsSubmitResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.JobRunner.Submit(ctx, "reindex", []byte(`{"full":true}`)); err != nil {
t.Fatal(err)
}
},
},
// --- embeddings ---
{
name: "embeddings_generate_embedding", wantMethod: "embeddings.generate_embedding",
resp: &abiv1.EmbeddingsGenerateEmbeddingResponse{Embedding: []float32{0.1, 0.2, 0.3}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.EmbeddingService.GenerateEmbedding(ctx, "text")
if err != nil || len(got) != 3 || got[0] != 0.1 {
t.Errorf("embedding = %v err = %v", got, err)
}
},
},
{
name: "embeddings_embed_content", wantMethod: "embeddings.embed_content",
resp: &abiv1.EmbeddingsEmbedContentResponse{Embedded: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.EmbeddingService.EmbedContent(ctx, "post", idRow, "text")
if err != nil || !got {
t.Errorf("embedded = %v err = %v", got, err)
}
},
},
{
name: "embeddings_is_available", wantMethod: "embeddings.is_available",
resp: &abiv1.EmbeddingsIsAvailableResponse{Available: true},
run: func(t *testing.T, cs plugin.CoreServices) {
if !cs.EmbeddingService.IsAvailable() {
t.Errorf("is_available = false")
}
},
},
// --- rag ---
{
name: "rag_query", wantMethod: "rag.query",
resp: &abiv1.RagQueryResponse{Results: []*abiv1.RagResult{
{Content: "chunk", Score: 0.9, Metadata: map[string]string{"src": "post"}},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.RAGService.Query(ctx, "q", 5)
if err != nil || len(got) != 1 || got[0].Score != 0.9 || got[0].Metadata["src"] != "post" {
t.Errorf("rag = %+v err = %v", got, err)
}
},
},
{
name: "rag_on_content_changed", wantMethod: "rag.on_content_changed",
resp: &abiv1.RagOnContentChangedResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.RAGService.OnContentChanged(ctx, "post", idRow)
},
},
// --- reviews ---
{
name: "reviews_submit_review", wantMethod: "reviews.submit_review",
resp: &abiv1.ReviewsSubmitReviewResponse{ReviewId: "rev-1"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.ReviewSubmitter.SubmitReview(ctx, plugin.SubmitReviewParams{
TableID: idTable, RowID: idRow, OverallRating: 4, ReviewText: "good",
Ratings: map[string]any{"food": 5}, Photos: []string{"media:1"},
})
if err != nil || got != "rev-1" {
t.Errorf("review id = %q err = %v", got, err)
}
},
},
// --- badges ---
{
name: "badges_refresh_badges", wantMethod: "badges.refresh_badges",
resp: &abiv1.BadgesRefreshBadgesResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.BadgeRefresher.RefreshBadges(ctx, idTable, idRow); err != nil {
t.Fatal(err)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f.name = tc.name
f.cannedResp = tc.resp
f.err = nil
f.gotMethod = ""
tc.run(t, cs)
if f.gotMethod != tc.wantMethod {
t.Errorf("method = %q, want %q", f.gotMethod, tc.wantMethod)
}
})
}
}
// fakeAbiErr is a transport error carrying an ABI code, like wasmguest.HostError.
type fakeAbiErr struct {
code abiv1.AbiErrorCode
msg string
}
func (e *fakeAbiErr) Error() string { return e.msg }
func (e *fakeAbiErr) AbiErrorCode() abiv1.AbiErrorCode { return e.code }
func TestErrorMappingCarriesFamilyMethod(t *testing.T) {
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED, msg: "denied"}}
cs := NewCoreServices(f.call)
f.name = "content_get_page" // reuse the request golden for the compare
_, err := cs.Content.GetPage(context.Background(), "about")
if err == nil {
t.Fatal("want error")
}
if got := err.Error(); !strings.Contains(got, "content.get_page") || !strings.Contains(got, "denied") {
t.Errorf("error %q lacks family/method or message", got)
}
}
func TestErrorMappingDeadlineBecomesContextDeadline(t *testing.T) {
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DEADLINE_EXCEEDED, msg: "deadline"}}
cs := NewCoreServices(f.call)
f.name = "crypto_encrypt_secret"
_, err := cs.Crypto.EncryptSecret("plain")
if err == nil {
t.Fatal("want error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("error %q does not wrap context.DeadlineExceeded", err)
}
if !strings.Contains(err.Error(), "crypto.encrypt_secret") {
t.Errorf("error %q lacks family/method", err)
}
}
func TestNilTransportFailsCleanly(t *testing.T) {
cs := NewCoreServices(nil)
_, err := cs.Content.GetPage(context.Background(), "about")
if err == nil || !strings.Contains(err.Error(), "no host transport") {
t.Errorf("nil transport error = %v", err)
}
}
// TestRAGRegisterContentFetcherStaysGuestSide verifies RegisterContentFetcher
// records fetchers locally (no host call) for HOOK_RAG_FETCH dispatch.
func TestRAGRegisterContentFetcherStaysGuestSide(t *testing.T) {
f := &fakeTransport{t: t}
cs := NewCoreServices(f.call)
rag, ok := cs.RAGService.(*RAGStub)
if !ok {
t.Fatalf("RAGService is %T, want *RAGStub", cs.RAGService)
}
called := false
rag.RegisterContentFetcher("post", func(context.Context, uuid.UUID) (string, string, error) {
called = true
return "T", "body", nil
})
if f.gotMethod != "" {
t.Errorf("RegisterContentFetcher made a host call %q", f.gotMethod)
}
if types := rag.FetcherTypes(); len(types) != 1 || types[0] != "post" {
t.Errorf("fetcher types = %v", types)
}
fetcher, ok := rag.Fetcher("post")
if !ok {
t.Fatal("fetcher not found")
}
if _, _, _ = fetcher(context.Background(), idRow); !called {
t.Error("registered fetcher not invoked")
}
}

View File

@ -0,0 +1,105 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/content"
"github.com/google/uuid"
)
// contentStub implements content.Content over content.* capability calls.
type contentStub struct{ base }
var _ content.Content = (*contentStub)(nil)
func (s *contentStub) GetAuthorProfile(ctx context.Context, id uuid.UUID) (*content.AuthorProfile, error) {
resp := &abiv1.ContentGetAuthorProfileResponse{}
if err := s.invoke(ctx, "get_author_profile", &abiv1.ContentGetAuthorProfileRequest{Id: id.String()}, resp); err != nil {
return nil, err
}
a := resp.GetAuthor()
if a == nil {
return nil, nil
}
return &content.AuthorProfile{
ID: parseUUID(a.GetId()),
Name: a.GetName(),
Slug: a.GetSlug(),
Bio: a.GetBio(),
AvatarURL: a.GetAvatarUrl(),
Website: a.GetWebsite(),
SocialLinks: a.GetSocialLinks(),
}, nil
}
func (s *contentStub) GetPage(ctx context.Context, slug string) (*content.PageInfo, error) {
resp := &abiv1.ContentGetPageResponse{}
if err := s.invoke(ctx, "get_page", &abiv1.ContentGetPageRequest{Slug: slug}, resp); err != nil {
return nil, err
}
p := resp.GetPage()
if p == nil {
return nil, nil
}
return &content.PageInfo{ID: parseUUID(p.GetId()), Slug: p.GetSlug(), Title: p.GetTitle()}, nil
}
func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostInfo, error) {
resp := &abiv1.ContentGetPostResponse{}
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
return nil, err
}
p := resp.GetPost()
if p == nil {
return nil, nil
}
return &content.PostInfo{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
Excerpt: p.GetExcerpt(),
FeaturedImageURL: p.GetFeaturedImageUrl(),
AuthorID: parseUUID(p.GetAuthorId()),
}, nil
}
// Slugify has no error channel in the interface; a transport failure degrades
// to the empty string (the caller treats an empty slug as "unavailable").
func (s *contentStub) Slugify(text string) string {
resp := &abiv1.ContentSlugifyResponse{}
if err := s.invoke(context.Background(), "slugify", &abiv1.ContentSlugifyRequest{Text: text}, resp); err != nil {
return ""
}
return resp.GetSlug()
}
func (s *contentStub) BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
docJSON, err := json.Marshal(doc)
if err != nil {
return ""
}
resp := &abiv1.ContentBlockNoteToHtmlResponse{}
if err := s.invoke(ctx, "block_note_to_html", &abiv1.ContentBlockNoteToHtmlRequest{DocJson: docJSON}, resp); err != nil {
return ""
}
return resp.GetHtml()
}
func (s *contentStub) GenerateExcerpt(html string, maxLen int) string {
resp := &abiv1.ContentGenerateExcerptResponse{}
req := &abiv1.ContentGenerateExcerptRequest{Html: html, MaxLen: int32(maxLen)}
if err := s.invoke(context.Background(), "generate_excerpt", req, resp); err != nil {
return ""
}
return resp.GetExcerpt()
}
func (s *contentStub) StripHTML(str string) string {
resp := &abiv1.ContentStripHtmlResponse{}
if err := s.invoke(context.Background(), "strip_html", &abiv1.ContentStripHtmlRequest{Html: str}, resp); err != nil {
return ""
}
return resp.GetText()
}

View File

@ -0,0 +1,49 @@
package caps
import (
"git.dev.alexdunmow.com/block/core/plugin"
)
// NewCoreServices assembles the guest-side CoreServices value plugins receive
// in their Load/HTTP/Job hooks: every capability interface is a stub that
// marshals to a "<family>.<method>" host call over the injected transport.
//
// The transport is injected (not a package global) so the marshaling logic is
// natively testable with a fake CallFunc; the wasm shim (package wasmguest,
// wasip1) passes its host_call-backed CallHost. A nil call yields stubs that
// fail every capability with a clear "no host transport" error — the shape
// used by DESCRIBE probes, which never reach a live host.
//
// Members intentionally left zero because they do NOT cross as capability
// calls (all documented in core/docs/wasm-abi.md §"Capability calls"):
//
// - Pool → the db.* driver messages (db.proto)
// - Interceptors → host-side connect options; RBAC from the manifest
// - MediaPath / AppURL → delivered in LoadRequest.host_config at load
// - CoreServiceBindings → static manifest.core_service_bindings; host mounts
func NewCoreServices(call CallFunc) plugin.CoreServices {
settings := &settingsStub{base{family: "settings", call: call}}
ai := &aiStub{base{family: "ai", call: call}}
return plugin.CoreServices{
Content: &contentStub{base{family: "content", call: call}},
Settings: settings,
SettingsUpdater: settings,
Gating: &gatingStub{base{family: "gating", call: call}},
Crypto: &cryptoStub{base{family: "crypto", call: call}},
Menus: &menusStub{base{family: "menus", call: call}},
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
PublicUsers: &usersStub{base{family: "users", call: call}},
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
Media: &mediaStub{base{family: "media", call: call}},
ToolRegistry: ai,
AITextCall: ai.textCall,
EmailSender: &emailStub{base{family: "email", call: call}},
Bridge: &bridgeStub{base{family: "bridge", call: call}},
ReviewSubmitter: &reviewsStub{base{family: "reviews", call: call}},
BadgeRefresher: &badgesStub{base{family: "badges", call: call}},
JobRunner: &jobsStub{base{family: "jobs", call: call}},
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
RAGService: NewRAGStub(call),
}
}

View File

@ -0,0 +1,30 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/crypto"
)
// cryptoStub implements crypto.Crypto over crypto.* capability calls. The
// interface takes no context; host calls run under the invoke deadline.
type cryptoStub struct{ base }
var _ crypto.Crypto = (*cryptoStub)(nil)
func (s *cryptoStub) EncryptSecret(plaintext string) (string, error) {
resp := &abiv1.CryptoEncryptSecretResponse{}
if err := s.invoke(context.Background(), "encrypt_secret", &abiv1.CryptoEncryptSecretRequest{Plaintext: plaintext}, resp); err != nil {
return "", err
}
return resp.GetCiphertext(), nil
}
func (s *cryptoStub) DecryptSecret(ciphertext string) (string, error) {
resp := &abiv1.CryptoDecryptSecretResponse{}
if err := s.invoke(context.Background(), "decrypt_secret", &abiv1.CryptoDecryptSecretRequest{Ciphertext: ciphertext}, resp); err != nil {
return "", err
}
return resp.GetPlaintext(), nil
}

View File

@ -0,0 +1,46 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/datasources"
"github.com/google/uuid"
)
// datasourcesStub implements datasources.Datasources over datasources.*
// capability calls. Items ([]any) and Meta (map[string]any) cross as JSON.
type datasourcesStub struct{ base }
var _ datasources.Datasources = (*datasourcesStub)(nil)
func (s *datasourcesStub) ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*datasources.Result, error) {
resp := &abiv1.DatasourcesResolveBucketResponse{}
req := &abiv1.DatasourcesResolveBucketRequest{BucketId: bucketID.String()}
if err := s.invoke(ctx, "resolve_bucket", req, resp); err != nil {
return nil, err
}
return datasourceResultFromProto(resp.GetResult()), nil
}
func (s *datasourcesStub) ResolveBucketByKey(ctx context.Context, bucketKey string) (*datasources.Result, error) {
resp := &abiv1.DatasourcesResolveBucketByKeyResponse{}
req := &abiv1.DatasourcesResolveBucketByKeyRequest{BucketKey: bucketKey}
if err := s.invoke(ctx, "resolve_bucket_by_key", req, resp); err != nil {
return nil, err
}
return datasourceResultFromProto(resp.GetResult()), nil
}
func datasourceResultFromProto(r *abiv1.DatasourceResult) *datasources.Result {
if r == nil {
return nil
}
out := &datasources.Result{Total: int(r.GetTotal())}
if items := r.GetItemsJson(); len(items) > 0 {
_ = json.Unmarshal(items, &out.Items)
}
out.Meta = unmarshalMap(r.GetMetaJson())
return out
}

View File

@ -0,0 +1,19 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
)
// emailStub implements plugin.EmailSender over the email.send capability call.
// The interface takes no context; the call runs under the invoke deadline.
type emailStub struct{ base }
var _ plugin.EmailSender = (*emailStub)(nil)
func (s *emailStub) Send(to, subject, body string) error {
req := &abiv1.EmailSendRequest{To: to, Subject: subject, Body: body}
return s.invoke(context.Background(), "send", req, &abiv1.EmailSendResponse{})
}

View File

@ -0,0 +1,46 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
)
// embeddingsStub implements plugin.EmbeddingService over embeddings.*
// capability calls.
type embeddingsStub struct{ base }
var _ plugin.EmbeddingService = (*embeddingsStub)(nil)
func (s *embeddingsStub) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) {
resp := &abiv1.EmbeddingsGenerateEmbeddingResponse{}
req := &abiv1.EmbeddingsGenerateEmbeddingRequest{Text: text}
if err := s.invoke(ctx, "generate_embedding", req, resp); err != nil {
return nil, err
}
return resp.GetEmbedding(), nil
}
func (s *embeddingsStub) EmbedContent(ctx context.Context, sourceType string, sourceID uuid.UUID, text string) (bool, error) {
resp := &abiv1.EmbeddingsEmbedContentResponse{}
req := &abiv1.EmbeddingsEmbedContentRequest{
SourceType: sourceType,
SourceId: sourceID.String(),
Text: text,
}
if err := s.invoke(ctx, "embed_content", req, resp); err != nil {
return false, err
}
return resp.GetEmbedded(), nil
}
// IsAvailable has no error channel; a transport failure reports unavailable.
func (s *embeddingsStub) IsAvailable() bool {
resp := &abiv1.EmbeddingsIsAvailableResponse{}
if err := s.invoke(context.Background(), "is_available", &abiv1.EmbeddingsIsAvailableRequest{}, resp); err != nil {
return false
}
return resp.GetAvailable()
}

View File

@ -0,0 +1,51 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/gating"
"github.com/google/uuid"
)
// gatingStub implements gating.Gating over gating.* capability calls.
type gatingStub struct{ base }
var _ gating.Gating = (*gatingStub)(nil)
func (s *gatingStub) GetSubscriberTierLevel(ctx context.Context, userID uuid.UUID) (int, error) {
resp := &abiv1.GatingGetSubscriberTierLevelResponse{}
req := &abiv1.GatingGetSubscriberTierLevelRequest{UserId: userID.String()}
if err := s.invoke(ctx, "get_subscriber_tier_level", req, resp); err != nil {
return 0, err
}
return int(resp.GetLevel()), nil
}
// EvaluateAccess mirrors the host's decision. The rule evaluation is a pure,
// deterministic function (gating.EvaluateAccess) so the interface exposes no
// error channel; the call still crosses the boundary to keep host and guest
// on identical logic, and falls back to the local pure function if the
// transport is unavailable.
func (s *gatingStub) EvaluateAccess(userTierLevel int, rule *gating.AccessRule) gating.AccessResult {
req := &abiv1.GatingEvaluateAccessRequest{UserTierLevel: int32(userTierLevel)}
if rule != nil {
req.Rule = &abiv1.AccessRule{
MinTierLevel: int32(rule.MinTierLevel),
OverrideTierId: rule.OverrideTierID,
TeaserMode: rule.TeaserMode,
TeaserPercent: int32(rule.TeaserPercent),
}
}
resp := &abiv1.GatingEvaluateAccessResponse{}
if err := s.invoke(context.Background(), "evaluate_access", req, resp); err != nil {
return gating.EvaluateAccess(userTierLevel, rule)
}
r := resp.GetResult()
return gating.AccessResult{
HasAccess: r.GetHasAccess(),
TeaserMode: r.GetTeaserMode(),
TeaserPercent: int(r.GetTeaserPercent()),
RequiredLevel: int(r.GetRequiredLevel()),
}
}

View File

@ -0,0 +1,18 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
)
// jobsStub implements plugin.JobRunner over the jobs.submit capability call.
type jobsStub struct{ base }
var _ plugin.JobRunner = (*jobsStub)(nil)
func (s *jobsStub) Submit(ctx context.Context, jobType string, config []byte) error {
req := &abiv1.JobsSubmitRequest{JobType: jobType, ConfigJson: config}
return s.invoke(ctx, "submit", req, &abiv1.JobsSubmitResponse{})
}

View File

@ -0,0 +1,36 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
)
// mediaStub implements plugin.Media over the media.deposit capability call.
type mediaStub struct{ base }
var _ plugin.Media = (*mediaStub)(nil)
func (s *mediaStub) Deposit(ctx context.Context, deposit plugin.MediaDeposit) (plugin.MediaResult, error) {
req := &abiv1.MediaDepositRequest{
Filename: deposit.Filename,
Data: deposit.Data,
AltText: deposit.AltText,
Folder: deposit.Folder,
Source: deposit.Source,
}
if deposit.ID != uuid.Nil {
req.Id = deposit.ID.String()
}
resp := &abiv1.MediaDepositResponse{}
if err := s.invoke(ctx, "deposit", req, resp); err != nil {
return plugin.MediaResult{}, err
}
return plugin.MediaResult{
ID: parseUUID(resp.GetId()),
Ref: resp.GetRef(),
Created: resp.GetCreated(),
}, nil
}

View File

@ -0,0 +1,55 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/menus"
"github.com/google/uuid"
)
// menusStub implements menus.Menus over menus.* capability calls.
type menusStub struct{ base }
var _ menus.Menus = (*menusStub)(nil)
func (s *menusStub) GetMenuByName(ctx context.Context, name string) (*menus.Menu, error) {
resp := &abiv1.MenusGetMenuByNameResponse{}
if err := s.invoke(ctx, "get_menu_by_name", &abiv1.MenusGetMenuByNameRequest{Name: name}, resp); err != nil {
return nil, err
}
m := resp.GetMenu()
if m == nil {
return nil, nil
}
return &menus.Menu{ID: parseUUID(m.GetId()), Name: m.GetName()}, nil
}
func (s *menusStub) GetMenuItems(ctx context.Context, menuID uuid.UUID) ([]menus.MenuItem, error) {
resp := &abiv1.MenusGetMenuItemsResponse{}
req := &abiv1.MenusGetMenuItemsRequest{MenuId: menuID.String()}
if err := s.invoke(ctx, "get_menu_items", req, resp); err != nil {
return nil, err
}
items := make([]menus.MenuItem, 0, len(resp.GetItems()))
for _, it := range resp.GetItems() {
mi := menus.MenuItem{
ID: parseUUID(it.GetId()),
MenuID: parseUUID(it.GetMenuId()),
Label: it.GetLabel(),
URL: it.GetUrl(),
PageSlug: it.GetPageSlug(),
SortOrder: it.GetSortOrder(),
OpenInNewTab: it.GetOpenInNewTab(),
CssClass: it.GetCssClass(),
ItemType: it.GetItemType(),
Icon: it.GetIcon(),
}
if it.ParentId != nil {
pid := parseUUID(it.GetParentId())
mi.ParentID = &pid
}
items = append(items, mi)
}
return items, nil
}

View File

@ -0,0 +1,78 @@
package caps
import (
"context"
"sort"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
"github.com/google/uuid"
)
// RAGStub implements plugin.RAGService. Query and OnContentChanged marshal out
// to rag.* capability calls, while RegisterContentFetcher records the fetcher
// guest-side: the host re-indexes by inverting the call as a HOOK_RAG_FETCH
// callback, so the wasm shim reads the recorded fetchers to dispatch it. The
// stub is exported so the shim can reach FetcherTypes/Fetcher.
type RAGStub struct {
base
fetchers map[string]plugin.ContentFetcher
}
var _ plugin.RAGService = (*RAGStub)(nil)
// NewRAGStub builds a RAG stub with the given transport. A nil transport
// (DESCRIBE probe, native build) still records fetchers so their content
// types can be captured into the manifest.
func NewRAGStub(call CallFunc) *RAGStub {
return &RAGStub{
base: base{family: "rag", call: call},
fetchers: make(map[string]plugin.ContentFetcher),
}
}
func (s *RAGStub) RegisterContentFetcher(contentType string, fetcher plugin.ContentFetcher) {
s.fetchers[contentType] = fetcher
}
func (s *RAGStub) Query(ctx context.Context, query string, limit int) ([]plugin.RAGResult, error) {
req := &abiv1.RagQueryRequest{Query: query, Limit: int32(limit)}
resp := &abiv1.RagQueryResponse{}
if err := s.invoke(ctx, "query", req, resp); err != nil {
return nil, err
}
results := make([]plugin.RAGResult, 0, len(resp.GetResults()))
for _, r := range resp.GetResults() {
results = append(results, plugin.RAGResult{
Content: r.GetContent(),
Score: r.GetScore(),
Metadata: r.GetMetadata(),
})
}
return results, nil
}
// OnContentChanged has no error channel; a transport failure is dropped (the
// host will re-index on its own schedule regardless).
func (s *RAGStub) OnContentChanged(ctx context.Context, contentType string, contentID uuid.UUID) {
req := &abiv1.RagOnContentChangedRequest{ContentType: contentType, ContentId: contentID.String()}
_ = s.invoke(ctx, "on_content_changed", req, &abiv1.RagOnContentChangedResponse{})
}
// Fetcher returns the content fetcher registered for a content type, for
// HOOK_RAG_FETCH dispatch by the wasm shim.
func (s *RAGStub) Fetcher(contentType string) (plugin.ContentFetcher, bool) {
f, ok := s.fetchers[contentType]
return f, ok
}
// FetcherTypes lists the registered content-fetcher types in sorted order
// (deterministic manifest capture).
func (s *RAGStub) FetcherTypes() []string {
types := make([]string, 0, len(s.fetchers))
for k := range s.fetchers {
types = append(types, k)
}
sort.Strings(types)
return types
}

View File

@ -0,0 +1,35 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/plugin"
)
// reviewsStub implements plugin.ReviewSubmitter over the reviews.submit_review
// capability call.
type reviewsStub struct{ base }
var _ plugin.ReviewSubmitter = (*reviewsStub)(nil)
func (s *reviewsStub) SubmitReview(ctx context.Context, params plugin.SubmitReviewParams) (string, error) {
req := &abiv1.ReviewsSubmitReviewRequest{
TableId: params.TableID.String(),
RowId: params.RowID.String(),
OverallRating: params.OverallRating,
ReviewText: params.ReviewText,
Photos: params.Photos,
}
if params.Ratings != nil {
if raw, err := json.Marshal(params.Ratings); err == nil {
req.RatingsJson = raw
}
}
resp := &abiv1.ReviewsSubmitReviewResponse{}
if err := s.invoke(ctx, "submit_review", req, resp); err != nil {
return "", err
}
return resp.GetReviewId(), nil
}

View File

@ -0,0 +1,44 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/settings"
)
// settingsStub implements both settings.Settings and settings.Updater over
// settings.* capability calls. One instance backs both CoreServices fields.
type settingsStub struct{ base }
var (
_ settings.Settings = (*settingsStub)(nil)
_ settings.Updater = (*settingsStub)(nil)
)
func (s *settingsStub) GetSiteSettings(ctx context.Context) (map[string]any, error) {
resp := &abiv1.SettingsGetSiteSettingsResponse{}
if err := s.invoke(ctx, "get_site_settings", &abiv1.SettingsGetSiteSettingsRequest{}, resp); err != nil {
return nil, err
}
return unmarshalMap(resp.GetSettingsJson()), nil
}
func (s *settingsStub) GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error) {
resp := &abiv1.SettingsGetPluginSettingsResponse{}
req := &abiv1.SettingsGetPluginSettingsRequest{PluginName: pluginName}
if err := s.invoke(ctx, "get_plugin_settings", req, resp); err != nil {
return nil, err
}
return unmarshalMap(resp.GetSettingsJson()), nil
}
func (s *settingsStub) UpdateSiteSetting(ctx context.Context, key string, value any) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return err
}
req := &abiv1.SettingsUpdateSiteSettingRequest{Key: key, ValueJson: valueJSON}
return s.invoke(ctx, "update_site_setting", req, &abiv1.SettingsUpdateSiteSettingResponse{})
}

View File

@ -0,0 +1,91 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/subscriptions"
"github.com/google/uuid"
)
// subscriptionsStub implements subscriptions.Subscriptions over
// subscriptions.* capability calls.
type subscriptionsStub struct{ base }
var _ subscriptions.Subscriptions = (*subscriptionsStub)(nil)
func (s *subscriptionsStub) GetUserTierLevel(ctx context.Context, userID uuid.UUID) (*subscriptions.TierLevel, error) {
resp := &abiv1.SubscriptionsGetUserTierLevelResponse{}
req := &abiv1.SubscriptionsGetUserTierLevelRequest{UserId: userID.String()}
if err := s.invoke(ctx, "get_user_tier_level", req, resp); err != nil {
return nil, err
}
tl := resp.GetTierLevel()
if tl == nil {
return nil, nil
}
return &subscriptions.TierLevel{Level: int(tl.GetLevel()), Features: tl.GetFeatures()}, nil
}
func (s *subscriptionsStub) GetTierBySlug(ctx context.Context, slug string) (*subscriptions.Tier, error) {
resp := &abiv1.SubscriptionsGetTierBySlugResponse{}
req := &abiv1.SubscriptionsGetTierBySlugRequest{Slug: slug}
if err := s.invoke(ctx, "get_tier_by_slug", req, resp); err != nil {
return nil, err
}
t := resp.GetTier()
if t == nil {
return nil, nil
}
tier := tierFromProto(t)
return &tier, nil
}
func (s *subscriptionsStub) ListTiers(ctx context.Context) ([]subscriptions.Tier, error) {
resp := &abiv1.SubscriptionsListTiersResponse{}
if err := s.invoke(ctx, "list_tiers", &abiv1.SubscriptionsListTiersRequest{}, resp); err != nil {
return nil, err
}
tiers := make([]subscriptions.Tier, 0, len(resp.GetTiers()))
for _, t := range resp.GetTiers() {
tiers = append(tiers, tierFromProto(t))
}
return tiers, nil
}
func (s *subscriptionsStub) ListActivePlans(ctx context.Context, tierID uuid.UUID) ([]subscriptions.Plan, error) {
resp := &abiv1.SubscriptionsListActivePlansResponse{}
req := &abiv1.SubscriptionsListActivePlansRequest{TierId: tierID.String()}
if err := s.invoke(ctx, "list_active_plans", req, resp); err != nil {
return nil, err
}
plans := make([]subscriptions.Plan, 0, len(resp.GetPlans()))
for _, p := range resp.GetPlans() {
plan := subscriptions.Plan{
ID: parseUUID(p.GetId()),
TierID: parseUUID(p.GetTierId()),
BillingInterval: p.GetBillingInterval(),
Amount: p.GetAmount(),
Currency: p.GetCurrency(),
IsActive: p.GetIsActive(),
}
if ts := p.GetCreatedAt(); ts != nil {
plan.CreatedAt = ts.AsTime()
}
plans = append(plans, plan)
}
return plans, nil
}
func tierFromProto(t *abiv1.Tier) subscriptions.Tier {
return subscriptions.Tier{
ID: parseUUID(t.GetId()),
Name: t.GetName(),
Slug: t.GetSlug(),
Level: int(t.GetLevel()),
Description: t.GetDescription(),
Features: t.GetFeatures(),
IsDefault: t.GetIsDefault(),
Position: int(t.GetPosition()),
}
}

View File

@ -0,0 +1,2 @@
summarizesysuser

View File

@ -0,0 +1,2 @@
generated

View File

@ -0,0 +1,2 @@
lookupLookupd"{"type":"object"}

View File

@ -0,0 +1,2 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

View File

@ -0,0 +1,2 @@
symposiumsearch

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@
symposiumsearch

View File

@ -0,0 +1,2 @@
{"type":"doc"}

View File

@ -0,0 +1,2 @@
<p>hi</p>

View File

@ -0,0 +1,2 @@
<p>long text</p>

View File

@ -0,0 +1,2 @@
short

View File

@ -0,0 +1,2 @@
$22222222-2222-2222-2222-222222222222

View File

@ -0,0 +1,5 @@
l
$22222222-2222-2222-2222-222222222222Adaada"hi*https://x/a.png2https://ada.dev:
ghada:
x@ada

View File

@ -0,0 +1,2 @@
about

View File

@ -0,0 +1,3 @@
7
$22222222-2222-2222-2222-222222222222aboutAbout Us

View File

@ -0,0 +1,2 @@
hello

View File

@ -0,0 +1,3 @@
g
$22222222-2222-2222-2222-222222222222helloHello"hi*media:x2$22222222-2222-2222-2222-222222222222

View File

@ -0,0 +1,2 @@
Hello World

View File

@ -0,0 +1,2 @@
hello-world

View File

@ -0,0 +1,2 @@
<b>plain</b>

View File

@ -0,0 +1,2 @@
plain

View File

@ -0,0 +1,2 @@
enc:abc

View File

@ -0,0 +1,2 @@
plain

View File

@ -0,0 +1,2 @@
plain

View File

@ -0,0 +1,2 @@
enc:abc

View File

@ -0,0 +1,2 @@
featured

View File

@ -0,0 +1,3 @@

[]

View File

@ -0,0 +1,2 @@
$66666666-6666-6666-6666-666666666666

View File

@ -0,0 +1,4 @@
#
[{"id":1},{"id":2}]
{"page":1}

View File

@ -0,0 +1,2 @@
to@x.comSubjBody

View File

@ -0,0 +1,2 @@
post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbbtext

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@
text

View File

@ -0,0 +1,2 @@
<0C><><EFBFBD>=<3D><>L><3E><><EFBFBD>>

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@

soft 

View File

@ -0,0 +1,3 @@
soft 

View File

@ -0,0 +1,2 @@
$11111111-1111-1111-1111-111111111111

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@
reindex {"full":true}

View File

@ -0,0 +1,2 @@
$99999999-9999-9999-9999-999999999999hero.jpgbytes"alt*f2plugin

View File

@ -0,0 +1,2 @@
$99999999-9999-9999-9999-999999999999*media:99999999-9999-9999-9999-999999999999

View File

@ -0,0 +1,2 @@
main

View File

@ -0,0 +1,3 @@
,
$33333333-3333-3333-3333-333333333333main

View File

@ -0,0 +1,2 @@
$33333333-3333-3333-3333-333333333333

View File

@ -0,0 +1,3 @@

$44444444-4444-4444-4444-444444444444$33333333-3333-3333-3333-333333333333Home"/*home2$55555555-5555-5555-5555-5555555555558@JnavRlinkZhome

View File

@ -0,0 +1,2 @@
post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

View File

@ -0,0 +1,2 @@
q

View File

@ -0,0 +1,4 @@

chunkÍĚĚĚĚĚě?
srcpost

View File

@ -0,0 +1,3 @@
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"good*
{"food":5}2media:1

View File

@ -0,0 +1,2 @@
rev-1

View File

@ -0,0 +1,2 @@
symposium

View File

@ -0,0 +1,2 @@
{"enabled":true}

View File

@ -0,0 +1,2 @@
&{"site_name":"Fixture","theme":"dark"}

View File

@ -0,0 +1,2 @@
theme"light"

View File

@ -0,0 +1,2 @@
gold

View File

@ -0,0 +1,3 @@
?
$77777777-7777-7777-7777-777777777777Goldgold *d2{}8@

View File

@ -0,0 +1,2 @@
$11111111-1111-1111-1111-111111111111

View File

@ -0,0 +1,2 @@
{"a":1}

View File

@ -0,0 +1,2 @@
$77777777-7777-7777-7777-777777777777

View File

@ -0,0 +1,3 @@
e
$88888888-8888-8888-8888-888888888888$77777777-7777-7777-7777-777777777777month ¤&*usd0:ÀÈžÒ

View File

@ -0,0 +1,3 @@
4
$77777777-7777-7777-7777-777777777777Goldgold 

View File

@ -0,0 +1,2 @@
$11111111-1111-1111-1111-111111111111

View File

@ -0,0 +1,3 @@
)
$11111111-1111-1111-1111-111111111111u

View File

@ -0,0 +1,2 @@
u

View File

@ -0,0 +1,3 @@
E
$11111111-1111-1111-1111-111111111111u@x.comu"U*a2b8Bmember

View File

@ -0,0 +1,47 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/auth"
"github.com/google/uuid"
)
// usersStub implements auth.PublicUsers over users.* capability calls.
type usersStub struct{ base }
var _ auth.PublicUsers = (*usersStub)(nil)
func (s *usersStub) GetByUsername(ctx context.Context, username string) (*auth.PublicUserProfile, error) {
resp := &abiv1.UsersGetByUsernameResponse{}
req := &abiv1.UsersGetByUsernameRequest{Username: username}
if err := s.invoke(ctx, "get_by_username", req, resp); err != nil {
return nil, err
}
return publicUserFromProto(resp.GetUser()), nil
}
func (s *usersStub) GetByID(ctx context.Context, id uuid.UUID) (*auth.PublicUserProfile, error) {
resp := &abiv1.UsersGetByIdResponse{}
if err := s.invoke(ctx, "get_by_id", &abiv1.UsersGetByIdRequest{Id: id.String()}, resp); err != nil {
return nil, err
}
return publicUserFromProto(resp.GetUser()), nil
}
func publicUserFromProto(u *abiv1.PublicUserProfile) *auth.PublicUserProfile {
if u == nil {
return nil
}
return &auth.PublicUserProfile{
ID: parseUUID(u.GetId()),
Email: u.GetEmail(),
Username: u.GetUsername(),
DisplayName: u.GetDisplayName(),
AvatarURL: u.GetAvatarUrl(),
Bio: u.GetBio(),
EmailVerified: u.GetEmailVerified(),
Role: u.GetRole(),
}
}

View File

@ -0,0 +1,34 @@
package caps
import (
"encoding/json"
"github.com/google/uuid"
)
// parseUUID parses a canonical UUID string, returning uuid.Nil for empty or
// malformed input (host-side values are always canonical; this mirrors the
// tolerant decode the render-context shim uses).
func parseUUID(s string) uuid.UUID {
if s == "" {
return uuid.Nil
}
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil
}
return id
}
// unmarshalMap decodes a JSON object into map[string]any, returning nil for
// empty or undecodable input (callers treat absent maps as nil).
func unmarshalMap(raw []byte) map[string]any {
if len(raw) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
return m
}

View File

@ -0,0 +1,118 @@
package wasmguest
// End-to-end proof that the guest capability stubs (package caps, wired into
// CoreServices by WO-WZ-003) marshal to the ABI, cross into a host over the
// generic host_call import, and return decoded values the plugin's unchanged
// service code consumes — all inside a real wazero-compiled wasm module.
//
// The host_call handler below is a throwaway fake capability table (like the
// WO-WZ-002 throwaway host), not the cms host. It answers exactly the three
// families the fixture's Load hook exercises.
import (
"context"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"google.golang.org/protobuf/proto"
)
// installBlockninjaHost instantiates the "blockninja" import module exporting
// host_call, mirroring the guest calling convention in reverse (read a
// HostCallRequest from guest memory, answer it, write a HostCallResponse back
// via the guest's bn_alloc, return the packed pointer).
func installBlockninjaHost(t *testing.T, ctx context.Context, r wazero.Runtime) {
t.Helper()
_, err := r.NewHostModuleBuilder("blockninja").
NewFunctionBuilder().
WithFunc(func(ctx context.Context, mod api.Module, ptr, size uint32) uint64 {
data, ok := mod.Memory().Read(ptr, size)
if !ok {
return 0
}
req := &abiv1.HostCallRequest{}
if err := proto.Unmarshal(data, req); err != nil {
return 0
}
out, err := proto.Marshal(fakeCapability(req.GetMethod(), req.GetPayload()))
if err != nil {
return 0
}
// Allocate the response in guest memory (bn_alloc may grow memory,
// so req is already decoded — data is no longer referenced).
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(out)))
if err != nil || len(res) == 0 || res[0] == 0 {
return 0
}
respPtr := uint32(res[0])
if !mod.Memory().Write(respPtr, out) {
return 0
}
return uint64(respPtr)<<32 | uint64(uint32(len(out)))
}).
Export("host_call").
Instantiate(ctx)
if err != nil {
t.Fatalf("instantiate blockninja host module: %v", err)
}
}
// fakeCapability answers the three capability methods the fixture Load hook
// calls; everything else is UNIMPLEMENTED.
func fakeCapability(method string, payload []byte) *abiv1.HostCallResponse {
pack := func(m proto.Message) *abiv1.HostCallResponse {
b, err := proto.Marshal(m)
if err != nil {
return &abiv1.HostCallResponse{Error: &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, Message: err.Error(),
}}
}
return &abiv1.HostCallResponse{Payload: b}
}
switch method {
case "content.get_page":
return pack(&abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Slug: "about", Title: "About Us"}})
case "settings.get_site_settings":
return pack(&abiv1.SettingsGetSiteSettingsResponse{SettingsJson: []byte(`{"site_name":"Fixture Site"}`)})
case "bridge.get_service":
return pack(&abiv1.BridgeGetServiceResponse{Available: false})
default:
return &abiv1.HostCallResponse{Error: &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, Message: "no fake for " + method,
}}
}
}
// TestWasmFixtureCapabilityRoundTrip drives HOOK_LOAD (which calls the
// capability stubs) then renders the report block that surfaces the results,
// proving deps.Content / deps.Settings / deps.Bridge cross the ABI end-to-end.
func TestWasmFixtureCapabilityRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
resp := w.invoke(t, abiv1.Hook_HOOK_LOAD, &abiv1.LoadRequest{})
if resp.GetError() != nil {
t.Fatalf("LOAD error: %v", resp.GetError())
}
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"report":true}`),
})
if resp.GetError() != nil {
t.Fatalf("RENDER_BLOCK error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal RenderBlockResponse: %v", err)
}
const want = "<p>capreport:page=About Us site=Fixture Site bridge_nil=true</p>"
if rb.GetHtml() != want {
t.Errorf("capability report html = %q, want %q", rb.GetHtml(), want)
}
t.Logf("capability round trip through wasm: %s", rb.GetHtml())
}

View File

@ -8,8 +8,8 @@ import (
"connectrpc.com/connect"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
"git.dev.alexdunmow.com/block/core/rbac"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
@ -197,14 +197,14 @@ func (g *guest) captureRagFetcherTypes() (types []string) {
if g.reg.Load == nil {
return nil
}
rag := &guestRAGService{fetchers: make(map[string]plugin.ContentFetcher)}
rag := caps.NewRAGStub(nil)
svcs := describeServices()
svcs.RAGService = rag
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
_ = g.reg.Load(svcs)
}()
return sortedKeys(rag.fetchers)
return rag.FetcherTypes()
}
// captureServiceRegistration probes ServiceHandlers to record RBAC method
@ -247,14 +247,14 @@ func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
}
}
// describeServices returns the CoreServices value used to probe
// registration funcs at publish time: only side-effect-free capture stubs
// are populated. Anything else a plugin touches during the probe panics and
// is recovered by the caller.
// describeServices returns the CoreServices value used to probe registration
// funcs at publish time. It uses the capability stubs with a nil transport:
// there is no live host during DESCRIBE, so any capability a plugin invokes
// returns a clean "no host transport" error instead of nil-panicking, letting
// the probe capture whatever static registrations (jobs, RAG fetchers)
// surround the call. Callers still recover, belt-and-suspenders.
func describeServices() plugin.CoreServices {
return plugin.CoreServices{
ToolRegistry: noopToolRegistry{},
}
return caps.NewCoreServices(nil)
}
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
@ -299,10 +299,6 @@ func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[st
}, methodRoles), nil
}
type noopToolRegistry struct{}
func (noopToolRegistry) Register(*ai.ToolDefinition) {}
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
// Register pass; real provisioning runs host-side at load.
type noopProvisioner struct{}

Some files were not shown because too many files have changed in this diff Show More