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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@

[]

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@

soft 

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@

chunkÍĚĚĚĚĚě?
srcpost

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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