diff --git a/docs/wasm-abi.md b/docs/wasm-abi.md index 1ae8b19..67518ac 100644 --- a/docs/wasm-abi.md +++ b/docs/wasm-abi.md @@ -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 _ = +(*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 `.` 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: diff --git a/plugin/wasmguest/caps/ai.go b/plugin/wasmguest/caps/ai.go new file mode 100644 index 0000000..d3dee2b --- /dev/null +++ b/plugin/wasmguest/caps/ai.go @@ -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 +} diff --git a/plugin/wasmguest/caps/badges.go b/plugin/wasmguest/caps/badges.go new file mode 100644 index 0000000..efe011b --- /dev/null +++ b/plugin/wasmguest/caps/badges.go @@ -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{}) +} diff --git a/plugin/wasmguest/caps/bridge.go b/plugin/wasmguest/caps/bridge.go new file mode 100644 index 0000000..0c2d303 --- /dev/null +++ b/plugin/wasmguest/caps/bridge.go @@ -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 +} diff --git a/plugin/wasmguest/caps/caps.go b/plugin/wasmguest/caps/caps.go new file mode 100644 index 0000000..d816f4e --- /dev/null +++ b/plugin/wasmguest/caps/caps.go @@ -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 +// '.', 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 ".", 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) +} diff --git a/plugin/wasmguest/caps/caps_roundtrip_test.go b/plugin/wasmguest/caps/caps_roundtrip_test.go new file mode 100644 index 0000000..a11b9b4 --- /dev/null +++ b/plugin/wasmguest/caps/caps_roundtrip_test.go @@ -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: "

hi

"}, + run: func(t *testing.T, cs plugin.CoreServices) { + if got := cs.Content.BlockNoteToHTML(ctx, map[string]any{"type": "doc"}); got != "

hi

" { + 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("

long text

", 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("plain"); 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") + } +} diff --git a/plugin/wasmguest/caps/content.go b/plugin/wasmguest/caps/content.go new file mode 100644 index 0000000..4a744a7 --- /dev/null +++ b/plugin/wasmguest/caps/content.go @@ -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() +} diff --git a/plugin/wasmguest/caps/coreservices.go b/plugin/wasmguest/caps/coreservices.go new file mode 100644 index 0000000..16e7188 --- /dev/null +++ b/plugin/wasmguest/caps/coreservices.go @@ -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 "." 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), + } +} diff --git a/plugin/wasmguest/caps/crypto.go b/plugin/wasmguest/caps/crypto.go new file mode 100644 index 0000000..f42e420 --- /dev/null +++ b/plugin/wasmguest/caps/crypto.go @@ -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 +} diff --git a/plugin/wasmguest/caps/datasources.go b/plugin/wasmguest/caps/datasources.go new file mode 100644 index 0000000..76d09ea --- /dev/null +++ b/plugin/wasmguest/caps/datasources.go @@ -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 +} diff --git a/plugin/wasmguest/caps/email.go b/plugin/wasmguest/caps/email.go new file mode 100644 index 0000000..481fded --- /dev/null +++ b/plugin/wasmguest/caps/email.go @@ -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{}) +} diff --git a/plugin/wasmguest/caps/embeddings.go b/plugin/wasmguest/caps/embeddings.go new file mode 100644 index 0000000..34a505a --- /dev/null +++ b/plugin/wasmguest/caps/embeddings.go @@ -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() +} diff --git a/plugin/wasmguest/caps/gating.go b/plugin/wasmguest/caps/gating.go new file mode 100644 index 0000000..df514eb --- /dev/null +++ b/plugin/wasmguest/caps/gating.go @@ -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()), + } +} diff --git a/plugin/wasmguest/caps/jobs.go b/plugin/wasmguest/caps/jobs.go new file mode 100644 index 0000000..fc414f7 --- /dev/null +++ b/plugin/wasmguest/caps/jobs.go @@ -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{}) +} diff --git a/plugin/wasmguest/caps/media.go b/plugin/wasmguest/caps/media.go new file mode 100644 index 0000000..ce157f2 --- /dev/null +++ b/plugin/wasmguest/caps/media.go @@ -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 +} diff --git a/plugin/wasmguest/caps/menus.go b/plugin/wasmguest/caps/menus.go new file mode 100644 index 0000000..e83574a --- /dev/null +++ b/plugin/wasmguest/caps/menus.go @@ -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 +} diff --git a/plugin/wasmguest/caps/rag.go b/plugin/wasmguest/caps/rag.go new file mode 100644 index 0000000..41651cc --- /dev/null +++ b/plugin/wasmguest/caps/rag.go @@ -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 +} diff --git a/plugin/wasmguest/caps/reviews.go b/plugin/wasmguest/caps/reviews.go new file mode 100644 index 0000000..9f30f30 --- /dev/null +++ b/plugin/wasmguest/caps/reviews.go @@ -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 +} diff --git a/plugin/wasmguest/caps/settings.go b/plugin/wasmguest/caps/settings.go new file mode 100644 index 0000000..9d53b47 --- /dev/null +++ b/plugin/wasmguest/caps/settings.go @@ -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{}) +} diff --git a/plugin/wasmguest/caps/subscriptions.go b/plugin/wasmguest/caps/subscriptions.go new file mode 100644 index 0000000..d11ce51 --- /dev/null +++ b/plugin/wasmguest/caps/subscriptions.go @@ -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()), + } +} diff --git a/plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb b/plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb new file mode 100644 index 0000000..f3cf862 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb @@ -0,0 +1,2 @@ + + summarizesysuser \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb b/plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb new file mode 100644 index 0000000..510eec0 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb @@ -0,0 +1,2 @@ + + generated \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb b/plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb new file mode 100644 index 0000000..b7c80fa --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb @@ -0,0 +1,2 @@ + +lookupLookupd"{"type":"object"} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb b/plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb b/plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb new file mode 100644 index 0000000..a05bc9d --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb @@ -0,0 +1,2 @@ + +$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb b/plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb b/plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb new file mode 100644 index 0000000..33f1edb --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb @@ -0,0 +1,2 @@ + + symposiumsearch \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb b/plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb new file mode 100644 index 0000000..e19a122 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb b/plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb new file mode 100644 index 0000000..33f1edb --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb @@ -0,0 +1,2 @@ + + symposiumsearch \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb b/plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb b/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb new file mode 100644 index 0000000..3d1a6d5 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb @@ -0,0 +1,2 @@ + +{"type":"doc"} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb new file mode 100644 index 0000000..2a94743 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb @@ -0,0 +1,2 @@ + +

hi

\ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb b/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb new file mode 100644 index 0000000..6cdbc2a --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb @@ -0,0 +1,2 @@ + +

long text

 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb new file mode 100644 index 0000000..ff132f6 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb @@ -0,0 +1,2 @@ + +short \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb b/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb new file mode 100644 index 0000000..b14e671 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb @@ -0,0 +1,2 @@ + +$22222222-2222-2222-2222-222222222222 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb new file mode 100644 index 0000000..fd8dffb --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb @@ -0,0 +1,5 @@ + +l +$22222222-2222-2222-2222-222222222222Adaada"hi*https://x/a.png2https://ada.dev: +ghada: +x@ada \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb b/plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb new file mode 100644 index 0000000..fc830ef --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb @@ -0,0 +1,2 @@ + +about \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb new file mode 100644 index 0000000..88727b1 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb @@ -0,0 +1,3 @@ + +7 +$22222222-2222-2222-2222-222222222222aboutAbout Us \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb b/plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb new file mode 100644 index 0000000..19b5c9b --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb @@ -0,0 +1,2 @@ + +hello \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb new file mode 100644 index 0000000..877d679 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb @@ -0,0 +1,3 @@ + +g +$22222222-2222-2222-2222-222222222222helloHello"hi*media:x2$22222222-2222-2222-2222-222222222222 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb b/plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb new file mode 100644 index 0000000..0cbd2cd --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb @@ -0,0 +1,2 @@ + + Hello World \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb new file mode 100644 index 0000000..67c72ed --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb @@ -0,0 +1,2 @@ + + hello-world \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb b/plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb new file mode 100644 index 0000000..8674672 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb @@ -0,0 +1,2 @@ + + plain \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb b/plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb new file mode 100644 index 0000000..be7ef69 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb @@ -0,0 +1,2 @@ + +plain \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb b/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb new file mode 100644 index 0000000..b01188b --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb @@ -0,0 +1,2 @@ + +enc:abc \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb b/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb new file mode 100644 index 0000000..be7ef69 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb @@ -0,0 +1,2 @@ + +plain \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb b/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb new file mode 100644 index 0000000..be7ef69 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb @@ -0,0 +1,2 @@ + +plain \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb b/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb new file mode 100644 index 0000000..b01188b --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb @@ -0,0 +1,2 @@ + +enc:abc \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb new file mode 100644 index 0000000..7cb24d0 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb @@ -0,0 +1,2 @@ + +featured \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb new file mode 100644 index 0000000..0d791ca --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb @@ -0,0 +1,3 @@ + + +[] \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb new file mode 100644 index 0000000..a0eadfa --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb @@ -0,0 +1,2 @@ + +$66666666-6666-6666-6666-666666666666 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb new file mode 100644 index 0000000..577cfab --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb @@ -0,0 +1,4 @@ + +# +[{"id":1},{"id":2}] +{"page":1} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/email_send_req.pb b/plugin/wasmguest/caps/testdata/golden/email_send_req.pb new file mode 100644 index 0000000..91b50ff --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/email_send_req.pb @@ -0,0 +1,2 @@ + +to@x.comSubjBody \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/email_send_resp.pb b/plugin/wasmguest/caps/testdata/golden/email_send_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb new file mode 100644 index 0000000..e36942b --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb @@ -0,0 +1,2 @@ + +post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbbtext \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb new file mode 100644 index 0000000..e19a122 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb new file mode 100644 index 0000000..b2b71e4 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb @@ -0,0 +1,2 @@ + +text \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb new file mode 100644 index 0000000..5d83ec7 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb @@ -0,0 +1,2 @@ + + ÍÌÌ=ÍÌL>š™™> \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb b/plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb new file mode 100644 index 0000000..e19a122 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb b/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb new file mode 100644 index 0000000..aa0ac66 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb @@ -0,0 +1,2 @@ + +soft  \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb b/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb new file mode 100644 index 0000000..6f892fd --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb @@ -0,0 +1,3 @@ + + +soft  \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb b/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb new file mode 100644 index 0000000..1bdd816 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb @@ -0,0 +1,2 @@ + +$11111111-1111-1111-1111-111111111111 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb b/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb new file mode 100644 index 0000000..d65dd8f --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb b/plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb new file mode 100644 index 0000000..ab25be6 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb @@ -0,0 +1,2 @@ + +reindex {"full":true} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb b/plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb b/plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb new file mode 100644 index 0000000..ee44428 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb @@ -0,0 +1,2 @@ + +$99999999-9999-9999-9999-999999999999hero.jpgbytes"alt*f2plugin \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb b/plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb new file mode 100644 index 0000000..a6e3d9c --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb @@ -0,0 +1,2 @@ + +$99999999-9999-9999-9999-999999999999*media:99999999-9999-9999-9999-999999999999 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb new file mode 100644 index 0000000..68d8818 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb @@ -0,0 +1,2 @@ + +main \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb new file mode 100644 index 0000000..1510231 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb @@ -0,0 +1,3 @@ + +, +$33333333-3333-3333-3333-333333333333main \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb new file mode 100644 index 0000000..3bde6cb --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb @@ -0,0 +1,2 @@ + +$33333333-3333-3333-3333-333333333333 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb new file mode 100644 index 0000000..010df2b --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb @@ -0,0 +1,3 @@ + +– +$44444444-4444-4444-4444-444444444444$33333333-3333-3333-3333-333333333333Home"/*home2$55555555-5555-5555-5555-5555555555558@JnavRlinkZhome \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_req.pb b/plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_req.pb new file mode 100644 index 0000000..6f286c1 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_req.pb @@ -0,0 +1,2 @@ + +post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_resp.pb b/plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/rag_query_req.pb b/plugin/wasmguest/caps/testdata/golden/rag_query_req.pb new file mode 100644 index 0000000..fc21e24 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/rag_query_req.pb @@ -0,0 +1,2 @@ + +q \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/rag_query_resp.pb b/plugin/wasmguest/caps/testdata/golden/rag_query_resp.pb new file mode 100644 index 0000000..fb42626 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/rag_query_resp.pb @@ -0,0 +1,4 @@ + + +chunkÍÌÌÌÌÌì? +srcpost \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_req.pb b/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_req.pb new file mode 100644 index 0000000..6652b06 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_req.pb @@ -0,0 +1,3 @@ + +$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"good* +{"food":5}2media:1 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_resp.pb b/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_resp.pb new file mode 100644 index 0000000..fd4fe73 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/reviews_submit_review_resp.pb @@ -0,0 +1,2 @@ + +rev-1 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_req.pb b/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_req.pb new file mode 100644 index 0000000..d592105 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_req.pb @@ -0,0 +1,2 @@ + + symposium \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_resp.pb b/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_resp.pb new file mode 100644 index 0000000..78bd4e9 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_resp.pb @@ -0,0 +1,2 @@ + +{"enabled":true} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_req.pb b/plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_req.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_resp.pb b/plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_resp.pb new file mode 100644 index 0000000..21ce717 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_resp.pb @@ -0,0 +1,2 @@ + +&{"site_name":"Fixture","theme":"dark"} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_req.pb b/plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_req.pb new file mode 100644 index 0000000..43b4e67 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_req.pb @@ -0,0 +1,2 @@ + +theme"light" \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_resp.pb b/plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_resp.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_req.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_req.pb new file mode 100644 index 0000000..57c446c --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_req.pb @@ -0,0 +1,2 @@ + +gold \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_resp.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_resp.pb new file mode 100644 index 0000000..f1f05db --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_resp.pb @@ -0,0 +1,3 @@ + +? +$77777777-7777-7777-7777-777777777777Goldgold *d2{}8@ \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_req.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_req.pb new file mode 100644 index 0000000..1bdd816 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_req.pb @@ -0,0 +1,2 @@ + +$11111111-1111-1111-1111-111111111111 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_resp.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_resp.pb new file mode 100644 index 0000000..d52f5e6 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_resp.pb @@ -0,0 +1,2 @@ + + {"a":1} \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_req.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_req.pb new file mode 100644 index 0000000..589f9a3 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_req.pb @@ -0,0 +1,2 @@ + +$77777777-7777-7777-7777-777777777777 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_resp.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_resp.pb new file mode 100644 index 0000000..915fe20 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_resp.pb @@ -0,0 +1,3 @@ + +e +$88888888-8888-8888-8888-888888888888$77777777-7777-7777-7777-777777777777month ¤&*usd0:ÀÈžÒ \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_req.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_req.pb new file mode 100644 index 0000000..e69de29 diff --git a/plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_resp.pb b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_resp.pb new file mode 100644 index 0000000..41e8fbc --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_resp.pb @@ -0,0 +1,3 @@ + +4 +$77777777-7777-7777-7777-777777777777Goldgold  \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/users_get_by_id_req.pb b/plugin/wasmguest/caps/testdata/golden/users_get_by_id_req.pb new file mode 100644 index 0000000..1bdd816 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/users_get_by_id_req.pb @@ -0,0 +1,2 @@ + +$11111111-1111-1111-1111-111111111111 \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/users_get_by_id_resp.pb b/plugin/wasmguest/caps/testdata/golden/users_get_by_id_resp.pb new file mode 100644 index 0000000..f7af707 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/users_get_by_id_resp.pb @@ -0,0 +1,3 @@ + +) +$11111111-1111-1111-1111-111111111111u \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/users_get_by_username_req.pb b/plugin/wasmguest/caps/testdata/golden/users_get_by_username_req.pb new file mode 100644 index 0000000..5fac564 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/users_get_by_username_req.pb @@ -0,0 +1,2 @@ + +u \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/users_get_by_username_resp.pb b/plugin/wasmguest/caps/testdata/golden/users_get_by_username_resp.pb new file mode 100644 index 0000000..5d3a76a --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/users_get_by_username_resp.pb @@ -0,0 +1,3 @@ + +E +$11111111-1111-1111-1111-111111111111u@x.comu"U*a2b8Bmember \ No newline at end of file diff --git a/plugin/wasmguest/caps/users.go b/plugin/wasmguest/caps/users.go new file mode 100644 index 0000000..a84debb --- /dev/null +++ b/plugin/wasmguest/caps/users.go @@ -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(), + } +} diff --git a/plugin/wasmguest/caps/util.go b/plugin/wasmguest/caps/util.go new file mode 100644 index 0000000..260fec8 --- /dev/null +++ b/plugin/wasmguest/caps/util.go @@ -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 +} diff --git a/plugin/wasmguest/caps_wasmhost_test.go b/plugin/wasmguest/caps_wasmhost_test.go new file mode 100644 index 0000000..6906bea --- /dev/null +++ b/plugin/wasmguest/caps_wasmhost_test.go @@ -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 = "

capreport:page=About Us site=Fixture Site bridge_nil=true

" + 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()) +} diff --git a/plugin/wasmguest/describe.go b/plugin/wasmguest/describe.go index 935233c..bcfed56 100644 --- a/plugin/wasmguest/describe.go +++ b/plugin/wasmguest/describe.go @@ -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{} diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go index c6c9d4a..c32c942 100644 --- a/plugin/wasmguest/dispatch.go +++ b/plugin/wasmguest/dispatch.go @@ -12,10 +12,18 @@ import ( abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" "git.dev.alexdunmow.com/block/core/plugin" + "git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps" "github.com/google/uuid" "google.golang.org/protobuf/proto" ) +// capTransport is the guest→host capability transport handed to the +// capability stubs (package caps). hostcalls.go (wasip1) binds it to CallHost in an init; +// on native builds it stays nil, so capability calls report "no host +// transport" rather than reaching a boundary that isn't there. Tests exercise +// the stubs directly with their own fake transport. +var capTransport caps.CallFunc + // current is the guest runtime installed by Serve. The bn_invoke export // (exports.go, wasip1) dispatches through it. var current *guest @@ -35,14 +43,15 @@ type guest struct { registerErr error // services is the CoreServices value handed to the registration's - // function fields (HTTPHandler, JobHandlers, Load). The capability-stub - // WO populates its interface fields with host_call-backed - // implementations; LOAD fills AppURL/MediaPath from HostConfig. + // function fields (HTTPHandler, JobHandlers, Load). Its interface fields + // are caps host_call-backed stubs (package caps); LOAD fills + // AppURL/MediaPath from HostConfig. services plugin.CoreServices - // ragFetchers records RAGService.RegisterContentFetcher calls made - // during LOAD, dispatched via HOOK_RAG_FETCH. - ragFetchers map[string]plugin.ContentFetcher + // rag is the guest-side RAG stub inside services. It records + // RegisterContentFetcher calls (dispatched via HOOK_RAG_FETCH) while its + // Query/OnContentChanged marshal out as capability calls. + rag *caps.RAGStub jobHandlers map[string]plugin.JobHandlerFunc jobHandlersInit bool @@ -54,12 +63,12 @@ type guest struct { // functions are addressable before any hook fires. func newGuest(reg plugin.PluginRegistration) *guest { g := &guest{ - reg: reg, - blocks: newCaptureBlockRegistry(), - templates: newCaptureTemplateRegistry(), - ragFetchers: make(map[string]plugin.ContentFetcher), + reg: reg, + blocks: newCaptureBlockRegistry(), + templates: newCaptureTemplateRegistry(), } - g.services.RAGService = &guestRAGService{fetchers: g.ragFetchers} + g.services = caps.NewCoreServices(capTransport) + g.rag, _ = g.services.RAGService.(*caps.RAGStub) g.registerErr = runRegister(reg, g.templates, g.blocks) return g } @@ -316,7 +325,7 @@ func (g *guest) ragFetch(ctx context.Context, payload []byte) (proto.Message, *a if err := proto.Unmarshal(payload, req); err != nil { return nil, decodeError("RagFetchRequest", err) } - fetcher, ok := g.ragFetchers[req.GetContentType()] + fetcher, ok := g.ragFetcher(req.GetContentType()) if !ok { return nil, &abiv1.AbiError{ Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, @@ -408,25 +417,16 @@ func errorResponse(code abiv1.AbiErrorCode, msg string) []byte { return b } -// --- guest-side RAG service (fetcher capture) --- - -// guestRAGService records content-fetcher registrations so HOOK_RAG_FETCH -// can dispatch to them. Query/OnContentChanged become host capability calls -// in the capability-stub WO; until then they report unimplemented. -type guestRAGService struct { - fetchers map[string]plugin.ContentFetcher +// ragFetcher returns the content fetcher the plugin registered for a content +// type (via RAGService.RegisterContentFetcher during LOAD), for +// HOOK_RAG_FETCH dispatch. Nil-safe when the guest has no RAG stub. +func (g *guest) ragFetcher(contentType string) (plugin.ContentFetcher, bool) { + if g.rag == nil { + return nil, false + } + return g.rag.Fetcher(contentType) } -func (s *guestRAGService) RegisterContentFetcher(contentType string, fetcher plugin.ContentFetcher) { - s.fetchers[contentType] = fetcher -} - -func (s *guestRAGService) Query(context.Context, string, int) ([]plugin.RAGResult, error) { - return nil, fmt.Errorf("wasmguest: rag.query host capability not wired yet") -} - -func (s *guestRAGService) OnContentChanged(context.Context, string, uuid.UUID) {} - // sortedKeys returns the map's keys in sorted order (deterministic manifests). func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) diff --git a/plugin/wasmguest/hostcalls.go b/plugin/wasmguest/hostcalls.go index 33b5b90..d7d7b0f 100644 --- a/plugin/wasmguest/hostcalls.go +++ b/plugin/wasmguest/hostcalls.go @@ -26,6 +26,11 @@ import ( //go:noescape func hostCall(ptr uint32, size uint32) uint64 +// init binds the capability-stub transport (package caps) to the real +// host_call-backed CallHost. Only wasip1 has a host to call; native builds +// leave caps' transport nil, so their capability calls fail cleanly. +func init() { capTransport = CallHost } + // HostError is a failed capability call's AbiError surfaced as a Go error. type HostError struct { Code abiv1.AbiErrorCode @@ -36,6 +41,11 @@ func (e *HostError) Error() string { return fmt.Sprintf("host call failed (%s): %s", e.Code, e.Message) } +// AbiErrorCode exposes the underlying ABI error code so the capability stubs +// (package caps) can map a DEADLINE_EXCEEDED reply onto context.DeadlineExceeded +// without importing this wasip1-only package. +func (e *HostError) AbiErrorCode() abiv1.AbiErrorCode { return e.Code } + // HostCall performs one guest→host capability call. method is // "." (e.g. "crypto.encrypt_secret"); payload is the // serialized family request message. It returns the serialized family diff --git a/plugin/wasmguest/testdata/fixture/registration.go b/plugin/wasmguest/testdata/fixture/registration.go index 783f9a3..a1d2917 100644 --- a/plugin/wasmguest/testdata/fixture/registration.go +++ b/plugin/wasmguest/testdata/fixture/registration.go @@ -13,6 +13,12 @@ import ( "git.dev.alexdunmow.com/block/core/templates" ) +// capReport is populated by the Load hook from capability calls, then +// surfaced through the greeting block ({"report": true}). It is the runtime +// proof that deps.Content / deps.Settings / deps.Bridge cross the wasm ABI +// end-to-end through the guest capability stubs (WO-WZ-003 acceptance). +var capReport string + // Registration is the fixture's plugin registration — the same shape a .so // plugin exports, untouched by the wasm migration. var Registration = plugin.PluginRegistration{ @@ -28,6 +34,9 @@ var Registration = plugin.PluginRegistration{ if boom, _ := content["boom"].(bool); boom { panic("fixture kaboom") } + if report, _ := content["report"].(bool); report { + return "

capreport:" + capReport + "

" + } name, _ := content["name"].(string) if name == "" { name = "world" @@ -39,6 +48,25 @@ var Registration = plugin.PluginRegistration{ return htmlString("" + title + "") }) }, + // Load exercises the guest capability stubs exactly as a real plugin's + // service code does — unchanged calls against deps.Content / deps.Settings + // / deps.Bridge — proving they compile and cross the ABI for wasip1. + Load: func(deps plugin.CoreServices) error { + page, err := deps.Content.GetPage(context.Background(), "about") + if err != nil { + capReport = "content-err:" + err.Error() + return nil + } + site, err := deps.Settings.GetSiteSettings(context.Background()) + if err != nil { + capReport = "settings-err:" + err.Error() + return nil + } + svc := deps.Bridge.GetService("other", "search") // cannot cross; expected nil + capReport = fmt.Sprintf("page=%s site=%v bridge_nil=%t", + page.Title, site["site_name"], svc == nil) + return nil + }, AdminPages: func() []plugin.AdminPage { return []plugin.AdminPage{{ Key: "wasmfixture", Title: "Wasm Fixture", Icon: "flask", Route: "/admin/wasmfixture", diff --git a/plugin/wasmguest/wasmhost_test.go b/plugin/wasmguest/wasmhost_test.go index 8c107a0..3badc10 100644 --- a/plugin/wasmguest/wasmhost_test.go +++ b/plugin/wasmguest/wasmhost_test.go @@ -51,6 +51,9 @@ func instantiateFixture(t *testing.T) *wasmInstance { r := wazero.NewRuntime(ctx) t.Cleanup(func() { _ = r.Close(ctx) }) wasi_snapshot_preview1.MustInstantiate(ctx, r) + // The guest now imports blockninja.host_call (capability stubs, WO-WZ-003), + // so the host must export it or instantiation fails. + installBlockninjaHost(t, ctx, r) wasmBytes, err := os.ReadFile(wasmPath) if err != nil {