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:
parent
5f4fa9db0f
commit
bec3a43f55
@ -97,6 +97,16 @@ A `packed` value of `0` means the callee could not even produce an envelope
|
||||
(allocation failure / trap); the caller treats it as
|
||||
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
||||
|
||||
> A logically-empty response is **not** packed `0`. A successful hook whose
|
||||
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
|
||||
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
|
||||
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
|
||||
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
|
||||
> `len==0 → return 0` shortcut made every successful empty-response hook —
|
||||
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
|
||||
> instance. The cms host in WO-WZ-006 must likewise not conflate a
|
||||
> zero-length payload with a missing envelope.)
|
||||
|
||||
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
||||
concurrency comes from the per-plugin instance pool.
|
||||
|
||||
@ -146,7 +156,52 @@ each pair mirrors one Go interface method from `CoreServices` 1:1
|
||||
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
||||
|
||||
The guest half of the SDK implements the existing Go interfaces as stubs
|
||||
marshaling to these calls, so plugin code compiles unchanged.
|
||||
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
|
||||
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
|
||||
the wasm shim binds `call` to the real `host_call` transport, tests inject a
|
||||
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
|
||||
instead of nil-panicking.
|
||||
|
||||
### Method disposition (every `CoreServices` member)
|
||||
|
||||
No silent gaps: each member is either a guest stub or served host-side.
|
||||
|
||||
| Member | Disposition |
|
||||
|---|---|
|
||||
| `Content` (7 methods) | **stub** — `caps/content.go` |
|
||||
| `Settings` / `SettingsUpdater` | **stub** — `caps/settings.go` (one value, both fields) |
|
||||
| `Gating` | **stub** — `caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
|
||||
| `Crypto` | **stub** — `caps/crypto.go` |
|
||||
| `Menus` | **stub** — `caps/menus.go` |
|
||||
| `Datasources` | **stub** — `caps/datasources.go` |
|
||||
| `PublicUsers` | **stub** — `caps/users.go` |
|
||||
| `Subscriptions` | **stub** — `caps/subscriptions.go` |
|
||||
| `Media` | **stub** — `caps/media.go` |
|
||||
| `ToolRegistry` + `AITextCall` | **stub** — `caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
|
||||
| `EmailSender` | **stub** — `caps/email.go` |
|
||||
| `Bridge` | **stub** — `caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
|
||||
| `ReviewSubmitter` | **stub** — `caps/reviews.go` |
|
||||
| `BadgeRefresher` | **stub** — `caps/badges.go` |
|
||||
| `JobRunner` | **stub** — `caps/jobs.go` |
|
||||
| `EmbeddingService` | **stub** — `caps/embeddings.go` |
|
||||
| `RAGService` | **stub** — `caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
|
||||
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
|
||||
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. Auth context reaches the guest via `HttpRequest` headers (host-side interceptors already ran). |
|
||||
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
|
||||
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
|
||||
|
||||
Interface satisfaction is proven at compile time by a `var _ <iface> =
|
||||
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
|
||||
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
|
||||
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
|
||||
crosses the ABI for real.
|
||||
|
||||
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
|
||||
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
|
||||
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
|
||||
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
|
||||
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
|
||||
zero value / best-effort on transport failure.
|
||||
|
||||
`CoreServices` members that do **not** cross as capability calls:
|
||||
|
||||
|
||||
52
plugin/wasmguest/caps/ai.go
Normal file
52
plugin/wasmguest/caps/ai.go
Normal 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
|
||||
}
|
||||
20
plugin/wasmguest/caps/badges.go
Normal file
20
plugin/wasmguest/caps/badges.go
Normal 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{})
|
||||
}
|
||||
35
plugin/wasmguest/caps/bridge.go
Normal file
35
plugin/wasmguest/caps/bridge.go
Normal 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
|
||||
}
|
||||
89
plugin/wasmguest/caps/caps.go
Normal file
89
plugin/wasmguest/caps/caps.go
Normal 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)
|
||||
}
|
||||
677
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal file
677
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
105
plugin/wasmguest/caps/content.go
Normal file
105
plugin/wasmguest/caps/content.go
Normal 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()
|
||||
}
|
||||
49
plugin/wasmguest/caps/coreservices.go
Normal file
49
plugin/wasmguest/caps/coreservices.go
Normal 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),
|
||||
}
|
||||
}
|
||||
30
plugin/wasmguest/caps/crypto.go
Normal file
30
plugin/wasmguest/caps/crypto.go
Normal 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
|
||||
}
|
||||
46
plugin/wasmguest/caps/datasources.go
Normal file
46
plugin/wasmguest/caps/datasources.go
Normal 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
|
||||
}
|
||||
19
plugin/wasmguest/caps/email.go
Normal file
19
plugin/wasmguest/caps/email.go
Normal 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{})
|
||||
}
|
||||
46
plugin/wasmguest/caps/embeddings.go
Normal file
46
plugin/wasmguest/caps/embeddings.go
Normal 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()
|
||||
}
|
||||
51
plugin/wasmguest/caps/gating.go
Normal file
51
plugin/wasmguest/caps/gating.go
Normal 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()),
|
||||
}
|
||||
}
|
||||
18
plugin/wasmguest/caps/jobs.go
Normal file
18
plugin/wasmguest/caps/jobs.go
Normal 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{})
|
||||
}
|
||||
36
plugin/wasmguest/caps/media.go
Normal file
36
plugin/wasmguest/caps/media.go
Normal 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
|
||||
}
|
||||
55
plugin/wasmguest/caps/menus.go
Normal file
55
plugin/wasmguest/caps/menus.go
Normal 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
|
||||
}
|
||||
78
plugin/wasmguest/caps/rag.go
Normal file
78
plugin/wasmguest/caps/rag.go
Normal 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
|
||||
}
|
||||
35
plugin/wasmguest/caps/reviews.go
Normal file
35
plugin/wasmguest/caps/reviews.go
Normal 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
|
||||
}
|
||||
44
plugin/wasmguest/caps/settings.go
Normal file
44
plugin/wasmguest/caps/settings.go
Normal 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{})
|
||||
}
|
||||
91
plugin/wasmguest/caps/subscriptions.go
Normal file
91
plugin/wasmguest/caps/subscriptions.go
Normal 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()),
|
||||
}
|
||||
}
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
summarizesysuser
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
generated
|
||||
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
lookupLookupd"{"type":"object"}
|
||||
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
symposiumsearch
|
||||
1
plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/bridge_get_service_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_register_service_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
symposiumsearch
|
||||
0
plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/bridge_register_service_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
{"type":"doc"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_block_note_to_html_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<p>hi</p>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<p>long text</p>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_generate_excerpt_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
short
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$22222222-2222-2222-2222-222222222222
|
||||
5
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb
vendored
Normal file
5
plugin/wasmguest/caps/testdata/golden/content_get_author_profile_resp.pb
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
l
|
||||
$22222222-2222-2222-2222-222222222222Adaada"hi*https://x/a.png2https://ada.dev:
|
||||
ghada:
|
||||
x@ada
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_page_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
about
|
||||
3
plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/content_get_page_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
7
|
||||
$22222222-2222-2222-2222-222222222222aboutAbout Us
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_get_post_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
hello
|
||||
3
plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/content_get_post_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
g
|
||||
$22222222-2222-2222-2222-222222222222helloHello"hi*media:x2$22222222-2222-2222-2222-222222222222
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_slugify_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
Hello World
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_slugify_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
hello-world
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<b>plain</b>
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_strip_html_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
enc:abc
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_decrypt_secret_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
plain
|
||||
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/crypto_encrypt_secret_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
enc:abc
|
||||
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
featured
|
||||
3
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_by_key_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
[]
|
||||
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$66666666-6666-6666-6666-666666666666
|
||||
4
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb
vendored
Normal file
4
plugin/wasmguest/caps/testdata/golden/datasources_resolve_bucket_resp.pb
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
#
|
||||
[{"id":1},{"id":2}]
|
||||
{"page":1}
|
||||
2
plugin/wasmguest/caps/testdata/golden/email_send_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/email_send_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
to@x.comSubjBody
|
||||
0
plugin/wasmguest/caps/testdata/golden/email_send_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/email_send_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbbtext
|
||||
1
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_embed_content_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
text
|
||||
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/embeddings_generate_embedding_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<0C><><EFBFBD>=<3D><>L><3E><><EFBFBD>>
|
||||
0
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_req.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/embeddings_is_available_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
soft
|
||||
3
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/gating_evaluate_access_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
soft
|
||||
2
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$11111111-1111-1111-1111-111111111111
|
||||
1
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb
vendored
Normal file
1
plugin/wasmguest/caps/testdata/golden/gating_get_subscriber_tier_level_resp.pb
vendored
Normal file
@ -0,0 +1 @@
|
||||
|
||||
2
plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/jobs_submit_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
reindex
{"full":true}
|
||||
0
plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/jobs_submit_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$99999999-9999-9999-9999-999999999999hero.jpgbytes"alt*f2plugin
|
||||
2
plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/media_deposit_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$99999999-9999-9999-9999-999999999999*media:99999999-9999-9999-9999-999999999999
|
||||
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
main
|
||||
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_by_name_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
,
|
||||
$33333333-3333-3333-3333-333333333333main
|
||||
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$33333333-3333-3333-3333-333333333333
|
||||
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/menus_get_menu_items_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
–
|
||||
$44444444-4444-4444-4444-444444444444$33333333-3333-3333-3333-333333333333Home"/*home2$55555555-5555-5555-5555-5555555555558@JnavRlinkZhome
|
||||
2
plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
post$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
0
plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/rag_on_content_changed_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/rag_query_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/rag_query_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
q
|
||||
4
plugin/wasmguest/caps/testdata/golden/rag_query_resp.pb
vendored
Normal file
4
plugin/wasmguest/caps/testdata/golden/rag_query_resp.pb
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
chunkÍĚĚĚĚĚě?
|
||||
srcpost
|
||||
3
plugin/wasmguest/caps/testdata/golden/reviews_submit_review_req.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/reviews_submit_review_req.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"good*
|
||||
{"food":5}2media:1
|
||||
2
plugin/wasmguest/caps/testdata/golden/reviews_submit_review_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/reviews_submit_review_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
rev-1
|
||||
2
plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
symposium
|
||||
2
plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/settings_get_plugin_settings_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
{"enabled":true}
|
||||
0
plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_req.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/settings_get_site_settings_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
&{"site_name":"Fixture","theme":"dark"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
theme"light"
|
||||
0
plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/settings_update_site_setting_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
gold
|
||||
3
plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/subscriptions_get_tier_by_slug_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
?
|
||||
$77777777-7777-7777-7777-777777777777Goldgold *d2{}8@
|
||||
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$11111111-1111-1111-1111-111111111111
|
||||
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/subscriptions_get_user_tier_level_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
{"a":1}
|
||||
2
plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$77777777-7777-7777-7777-777777777777
|
||||
3
plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/subscriptions_list_active_plans_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
e
|
||||
$88888888-8888-8888-8888-888888888888$77777777-7777-7777-7777-777777777777month ¤&*usd0:ÀÈžÒ
|
||||
0
plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_req.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_req.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/subscriptions_list_tiers_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
4
|
||||
$77777777-7777-7777-7777-777777777777Goldgold
|
||||
2
plugin/wasmguest/caps/testdata/golden/users_get_by_id_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/users_get_by_id_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$11111111-1111-1111-1111-111111111111
|
||||
3
plugin/wasmguest/caps/testdata/golden/users_get_by_id_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/users_get_by_id_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
)
|
||||
$11111111-1111-1111-1111-111111111111u
|
||||
2
plugin/wasmguest/caps/testdata/golden/users_get_by_username_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/users_get_by_username_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
u
|
||||
3
plugin/wasmguest/caps/testdata/golden/users_get_by_username_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/users_get_by_username_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
E
|
||||
$11111111-1111-1111-1111-111111111111u@x.comu"U*a2b8Bmember
|
||||
47
plugin/wasmguest/caps/users.go
Normal file
47
plugin/wasmguest/caps/users.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
34
plugin/wasmguest/caps/util.go
Normal file
34
plugin/wasmguest/caps/util.go
Normal 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
|
||||
}
|
||||
118
plugin/wasmguest/caps_wasmhost_test.go
Normal file
118
plugin/wasmguest/caps_wasmhost_test.go
Normal 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())
|
||||
}
|
||||
@ -8,8 +8,8 @@ import (
|
||||
|
||||
"connectrpc.com/connect"
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/core/ai"
|
||||
"git.dev.alexdunmow.com/block/core/plugin"
|
||||
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
|
||||
"git.dev.alexdunmow.com/block/core/rbac"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@ -197,14 +197,14 @@ func (g *guest) captureRagFetcherTypes() (types []string) {
|
||||
if g.reg.Load == nil {
|
||||
return nil
|
||||
}
|
||||
rag := &guestRAGService{fetchers: make(map[string]plugin.ContentFetcher)}
|
||||
rag := caps.NewRAGStub(nil)
|
||||
svcs := describeServices()
|
||||
svcs.RAGService = rag
|
||||
func() {
|
||||
defer func() { recover() }() //nolint:errcheck // publish-time probe
|
||||
_ = g.reg.Load(svcs)
|
||||
}()
|
||||
return sortedKeys(rag.fetchers)
|
||||
return rag.FetcherTypes()
|
||||
}
|
||||
|
||||
// captureServiceRegistration probes ServiceHandlers to record RBAC method
|
||||
@ -247,14 +247,14 @@ func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
|
||||
}
|
||||
}
|
||||
|
||||
// describeServices returns the CoreServices value used to probe
|
||||
// registration funcs at publish time: only side-effect-free capture stubs
|
||||
// are populated. Anything else a plugin touches during the probe panics and
|
||||
// is recovered by the caller.
|
||||
// describeServices returns the CoreServices value used to probe registration
|
||||
// funcs at publish time. It uses the capability stubs with a nil transport:
|
||||
// there is no live host during DESCRIBE, so any capability a plugin invokes
|
||||
// returns a clean "no host transport" error instead of nil-panicking, letting
|
||||
// the probe capture whatever static registrations (jobs, RAG fetchers)
|
||||
// surround the call. Callers still recover, belt-and-suspenders.
|
||||
func describeServices() plugin.CoreServices {
|
||||
return plugin.CoreServices{
|
||||
ToolRegistry: noopToolRegistry{},
|
||||
}
|
||||
return caps.NewCoreServices(nil)
|
||||
}
|
||||
|
||||
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
|
||||
@ -299,10 +299,6 @@ func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[st
|
||||
}, methodRoles), nil
|
||||
}
|
||||
|
||||
type noopToolRegistry struct{}
|
||||
|
||||
func (noopToolRegistry) Register(*ai.ToolDefinition) {}
|
||||
|
||||
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
|
||||
// Register pass; real provisioning runs host-side at load.
|
||||
type noopProvisioner struct{}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user