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
|
(allocation failure / trap); the caller treats it as
|
||||||
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
||||||
|
|
||||||
|
> A logically-empty response is **not** packed `0`. A successful hook whose
|
||||||
|
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
|
||||||
|
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
|
||||||
|
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
|
||||||
|
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
|
||||||
|
> `len==0 → return 0` shortcut made every successful empty-response hook —
|
||||||
|
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
|
||||||
|
> instance. The cms host in WO-WZ-006 must likewise not conflate a
|
||||||
|
> zero-length payload with a missing envelope.)
|
||||||
|
|
||||||
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
||||||
concurrency comes from the per-plugin instance pool.
|
concurrency comes from the per-plugin instance pool.
|
||||||
|
|
||||||
@ -146,7 +156,52 @@ each pair mirrors one Go interface method from `CoreServices` 1:1
|
|||||||
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
||||||
|
|
||||||
The guest half of the SDK implements the existing Go interfaces as stubs
|
The guest half of the SDK implements the existing Go interfaces as stubs
|
||||||
marshaling to these calls, so plugin code compiles unchanged.
|
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
|
||||||
|
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
|
||||||
|
the wasm shim binds `call` to the real `host_call` transport, tests inject a
|
||||||
|
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
|
||||||
|
instead of nil-panicking.
|
||||||
|
|
||||||
|
### Method disposition (every `CoreServices` member)
|
||||||
|
|
||||||
|
No silent gaps: each member is either a guest stub or served host-side.
|
||||||
|
|
||||||
|
| Member | Disposition |
|
||||||
|
|---|---|
|
||||||
|
| `Content` (7 methods) | **stub** — `caps/content.go` |
|
||||||
|
| `Settings` / `SettingsUpdater` | **stub** — `caps/settings.go` (one value, both fields) |
|
||||||
|
| `Gating` | **stub** — `caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
|
||||||
|
| `Crypto` | **stub** — `caps/crypto.go` |
|
||||||
|
| `Menus` | **stub** — `caps/menus.go` |
|
||||||
|
| `Datasources` | **stub** — `caps/datasources.go` |
|
||||||
|
| `PublicUsers` | **stub** — `caps/users.go` |
|
||||||
|
| `Subscriptions` | **stub** — `caps/subscriptions.go` |
|
||||||
|
| `Media` | **stub** — `caps/media.go` |
|
||||||
|
| `ToolRegistry` + `AITextCall` | **stub** — `caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
|
||||||
|
| `EmailSender` | **stub** — `caps/email.go` |
|
||||||
|
| `Bridge` | **stub** — `caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
|
||||||
|
| `ReviewSubmitter` | **stub** — `caps/reviews.go` |
|
||||||
|
| `BadgeRefresher` | **stub** — `caps/badges.go` |
|
||||||
|
| `JobRunner` | **stub** — `caps/jobs.go` |
|
||||||
|
| `EmbeddingService` | **stub** — `caps/embeddings.go` |
|
||||||
|
| `RAGService` | **stub** — `caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
|
||||||
|
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
|
||||||
|
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. Auth context reaches the guest via `HttpRequest` headers (host-side interceptors already ran). |
|
||||||
|
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
|
||||||
|
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
|
||||||
|
|
||||||
|
Interface satisfaction is proven at compile time by a `var _ <iface> =
|
||||||
|
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
|
||||||
|
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
|
||||||
|
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
|
||||||
|
crosses the ABI for real.
|
||||||
|
|
||||||
|
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
|
||||||
|
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
|
||||||
|
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
|
||||||
|
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
|
||||||
|
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
|
||||||
|
zero value / best-effort on transport failure.
|
||||||
|
|
||||||
`CoreServices` members that do **not** cross as capability calls:
|
`CoreServices` members that do **not** cross as capability calls:
|
||||||
|
|
||||||
|
|||||||
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"
|
"connectrpc.com/connect"
|
||||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||||
"git.dev.alexdunmow.com/block/core/ai"
|
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/core/plugin"
|
||||||
|
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
|
||||||
"git.dev.alexdunmow.com/block/core/rbac"
|
"git.dev.alexdunmow.com/block/core/rbac"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
@ -197,14 +197,14 @@ func (g *guest) captureRagFetcherTypes() (types []string) {
|
|||||||
if g.reg.Load == nil {
|
if g.reg.Load == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rag := &guestRAGService{fetchers: make(map[string]plugin.ContentFetcher)}
|
rag := caps.NewRAGStub(nil)
|
||||||
svcs := describeServices()
|
svcs := describeServices()
|
||||||
svcs.RAGService = rag
|
svcs.RAGService = rag
|
||||||
func() {
|
func() {
|
||||||
defer func() { recover() }() //nolint:errcheck // publish-time probe
|
defer func() { recover() }() //nolint:errcheck // publish-time probe
|
||||||
_ = g.reg.Load(svcs)
|
_ = g.reg.Load(svcs)
|
||||||
}()
|
}()
|
||||||
return sortedKeys(rag.fetchers)
|
return rag.FetcherTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
// captureServiceRegistration probes ServiceHandlers to record RBAC method
|
// captureServiceRegistration probes ServiceHandlers to record RBAC method
|
||||||
@ -247,14 +247,14 @@ func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// describeServices returns the CoreServices value used to probe
|
// describeServices returns the CoreServices value used to probe registration
|
||||||
// registration funcs at publish time: only side-effect-free capture stubs
|
// funcs at publish time. It uses the capability stubs with a nil transport:
|
||||||
// are populated. Anything else a plugin touches during the probe panics and
|
// there is no live host during DESCRIBE, so any capability a plugin invokes
|
||||||
// is recovered by the caller.
|
// returns a clean "no host transport" error instead of nil-panicking, letting
|
||||||
|
// the probe capture whatever static registrations (jobs, RAG fetchers)
|
||||||
|
// surround the call. Callers still recover, belt-and-suspenders.
|
||||||
func describeServices() plugin.CoreServices {
|
func describeServices() plugin.CoreServices {
|
||||||
return plugin.CoreServices{
|
return caps.NewCoreServices(nil)
|
||||||
ToolRegistry: noopToolRegistry{},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
|
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
|
||||||
@ -299,10 +299,6 @@ func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[st
|
|||||||
}, methodRoles), nil
|
}, methodRoles), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noopToolRegistry struct{}
|
|
||||||
|
|
||||||
func (noopToolRegistry) Register(*ai.ToolDefinition) {}
|
|
||||||
|
|
||||||
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
|
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
|
||||||
// Register pass; real provisioning runs host-side at load.
|
// Register pass; real provisioning runs host-side at load.
|
||||||
type noopProvisioner struct{}
|
type noopProvisioner struct{}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user