Compare commits

...

9 Commits

Author SHA1 Message Date
Alex Dunmow
40ba4ee9de bnwasm: nil []string→NULL, fatten DbValue fixtures, doc text[] NULL-element limit
Close three WO-WZ-004 review gaps in the guest db driver:

- toDbValue: nil []string now marshals to DbValue_Null (matching []byte /
  json.RawMessage); empty-but-non-nil stays a non-NULL empty text[].
- DbValueFixtures: add edge entries (zero time.Time, negative + very-large
  numeric strings, empty text[], and text[] elements forcing encodePgTextArray
  quoting/escaping). Covered automatically by the table-driven round-trip and
  driver-value tests; new dbvalue_test.go covers the toDbValue nil convention.
- Document that TextArray cannot represent a NULL array element (repeated
  string has no per-element NULL) in the fixtures file and docs/wasm-abi.md,
  a contract limit the WO-WZ-007 host executor must also honor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:49:49 +08:00
Alex Dunmow
081bacf2ab feat(wasmguest): wire bnwasm Pool into guest CoreServices (WO-WZ-004)
Bind the db.* driver over the same transport as the capability stubs so
deps.Pool keeps working for plugin sqlc code:

- dispatch.go: g.services.Pool = bnwasm.NewPool(capTransport) (nil on
  native/DESCRIBE → fails cleanly, matching the caps stubs).
- hostcalls.go (wasip1): also bind the "bnwasm" database/sql driver's
  process-global transport to CallHost.
- docs/wasm-abi.md: document the implemented guest driver + pgx-primary
  rationale under the Pool disposition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:41:56 +08:00
Alex Dunmow
7881aeee05 feat(wasmguest): guest DB driver bnwasm (WO-WZ-004)
Guest-side database access over the db.* host calls (abiv1 db.proto),
two surfaces sharing one injected transport:

- database/sql driver registered as "bnwasm" (driver.go) — QueryContext/
  ExecContext/BeginTx over db.query/db.exec/db.tx_*; named args rejected.
- plugin.Pool (pool.go) handing out a pgx.Tx-shaped value (tx.go), so the
  pgx-flavored sqlc DBTX every current plugin generates against
  (sql_package: pgx/v5) is satisfied with no source edits. This is the
  primary path: their DBTX needs pgconn.CommandTag/pgx.Rows/pgx.Row, which
  database/sql cannot produce.

DbValue↔Go mapping (dbvalue.go) covers all 11 oneof arms both directions;
DbError surfaces as *pgconn.PgError (SQLSTATE preserved for errors.As);
nested tx/savepoints rejected with a clear error (no fleet plugin uses
them). The scan contract is pinned in the exported DbValueFixtures table
(dbvalue_fixtures.go) that the WO-WZ-007 host executor mirrors.

Tests: driver_test.go (fake host — every DbValue variant round-trips with
correct scan types, exec rows-affected, ordered host-call assertions for
tx commit/rollback sequences, post-rollback autocommit carries no handle)
and sqlcgen_test.go (vendored sqlc-style Queries + WithTx run against the
fake host). Native + wasip1 builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:41:45 +08:00
Alex Dunmow
bec3a43f55 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>
2026-07-03 14:23:58 +08:00
Alex Dunmow
5f4fa9db0f fix(wasmguest): frame empty InvokeResponse as (ptr,0), not packed 0
A successful hook whose response message has no set fields (LoadResponse,
UnloadResponse) proto-marshals to zero bytes. bn_invoke's `len==0 → return 0`
shortcut collided with the "callee could not produce an envelope" sentinel, so
every successful empty-response hook — notably HOOK_LOAD — looked like an
INTERNAL failure and got the instance discarded. Frame the empty case as
(ptr, 0) with a real 1-byte-backed pointer instead; the host reads zero bytes
into a valid empty InvokeResponse.

Surfaced by the WO-WZ-003 end-to-end capability test (first exercise of LOAD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:23:36 +08:00
Alex Dunmow
dc621e6d96 chore(ninja): check fmt.Fprintln returns in publish warnings (errcheck)
Pre-existing strict-lint failures in the publish warning helpers were
the only thing keeping check-safety's Go lint lane red for the module;
discard the writer errors explicitly (warnings are best-effort output).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:46 +08:00
Alex Dunmow
9bbc793563 test(wasmguest): reactor fixture plugin + throwaway wazero host (WO-WZ-002)
testdata/fixture is the acceptance fixture: one block, one template, one
admin page, plus the two-line reactor boilerplate main. wasmhost_test.go
compiles it with GOOS=wasip1 GOARCH=wasm -buildmode=c-shared (15.7 MiB,
the WO-WZ-012 memory-budget baseline) and drives it through wazero
exactly per wasm-abi.md: _initialize as the start function, request
bytes through bn_alloc, DESCRIBE returning a decodable manifest,
same-instance block+template renders, a panicking BlockFunc surfacing as
ABI_ERROR_CODE_INTERNAL with the instance still callable, and a
non-bn_alloc request pointer rejected as DECODE.

wazero v1.12.0 joins go.mod as a test-only dependency (approved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:39 +08:00
Alex Dunmow
c35199f0bc feat(wasmguest): guest runtime shim for the wasm plugin ABI (WO-WZ-002)
The wasip1 half of the ABI: bn_alloc/bn_invoke/bn_free exports with a
live-pin map so the GC never frees host-visible buffers, the generic
`blockninja.host_call` import (single import decided over per-family
symbols; recorded in wasm-abi.md), and a dispatch table adapting an
unmodified plugin.PluginRegistration to all nine v1 hooks. Panics inside
plugin hooks come back as ABI_ERROR_CODE_INTERNAL — the instance stays
callable; traps stay reserved for runtime corruption.

DESCRIBE builds the PluginManifest from the registration's static funcs
plus a capture-only Register pass (block metas via the same
PluginBlockRegistry prefixing the .so loader applies, template/system/
page-template/email-wrapper keys), probes JobHandlers/ServiceHandlers/
Load with capture-only services for job types, RBAC roles, core-service
bindings, and RAG fetcher types. RenderContext values are rehydrated
through the exact core/blocks context keys, so existing block code
reading from ctx works unchanged.

Plugins build in REACTOR mode (go build -buildmode=c-shared): init()
calls wasmguest.Serve (non-blocking), main is never called, and the host
runs _initialize before any bn_invoke. Command mode deadlocks or exits
(verified against wazero v1.12.0) — documented prominently in
wasm-abi.md, which also now reconciles the import module namespace to
`blockninja` and requires bn_alloc'd buffers on both directions.

Dispatch/describe/context logic is buildable on every GOOS; only
exports.go and hostcalls.go carry the wasip1 tag. dispatch_test.go
covers describe, hook routing, envelope mismatch, decode failures,
template-override resolution, panic recovery, and lifecycle hooks
natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:24 +08:00
Alex Dunmow
704b046727 feat(abi): wasm plugin ABI protobuf schema v1 (WO-WZ-001)
Defines the host<->guest wire contract for the .so -> wazero migration as a
repo-local buf module (abi/proto/v1, generated Go in abi/v1):

- manifest.proto: PluginManifest mirroring every static field of
  plugin.PluginRegistration (abi_version, deps, block/template registrations,
  admin pages, settings schema, theme presets, fonts, master pages, AI
  actions, RBAC method roles, CSS manifest, icon packs, directory
  extensions, job types, RAG fetcher types, settings panel, hook flags,
  core service bindings)
- invoke.proto: bn_invoke envelope, Hook catalog (RENDER_BLOCK,
  RENDER_TEMPLATE, HANDLE_HTTP, JOB, LOAD, UNLOAD, RAG_FETCH, MEDIA_HOOK,
  DESCRIBE), AbiError, and job/lifecycle/rag/media/describe payloads
- render.proto: RenderBlock/RenderTemplate payloads + RenderContext
  enumerating every ctx value from blocks/context.go (incl. BlockContext 1:1)
- http.proto: buffered HttpRequest/HttpResponse for HANDLE_HTTP
- db.proto: guest sql-driver messages (DbValue oneof over pgx-mappable
  types, query/exec/tx with host-side tx handles, SQLSTATE-carrying DbError)
- capability.proto: HostCall envelope + one request/response pair per
  CoreServices interface method (content, settings+update, gating, crypto,
  menus, datasources, users, subscriptions, media.deposit, email.send,
  ai.text_call, ai.tools.register, bridge, jobs.submit, embeddings, rag,
  reviews, badges.refresh)

The abi/ buf module is deliberately separate from the repo-root config:
proto/ is the shared block/proto submodule (service API contracts); the ABI
is SDK-internal and versions in lockstep with the guest shim. New `make abi`
target runs buf lint + generate. docs/wasm-abi.md documents the ptr+len
calling convention (bn_alloc, bn_invoke, packed u64), hook catalog, error
semantics, abi_version evolution rules, and the full registration/
CoreServices coverage tables.

Verified: `cd abi && buf lint` exit 0; `go build ./...` exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:47:00 +08:00
138 changed files with 18397 additions and 12 deletions

View File

@ -20,6 +20,13 @@ install-ninja:
proto:
buf generate --path proto/orchestrator/v1/plugin_registry.proto
# Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf
# module under abi/ — deliberately not part of the proto/ submodule; see
# abi/buf.yaml and docs/wasm-abi.md). Emits Go into abi/v1/.
.PHONY: abi
abi:
cd abi && buf lint && buf generate
.PHONY: update-sdk
update-sdk:
@set -e; \

12
abi/buf.gen.yaml Normal file
View File

@ -0,0 +1,12 @@
# Codegen for the wasm plugin ABI (WO-WZ-001).
#
# go_package is set explicitly in each .proto (managed mode not needed —
# unlike the proto/ submodule, we own these files). With
# paths=source_relative and out: ., v1/*.proto emits to abi/v1/*.pb.go,
# i.e. Go package git.dev.alexdunmow.com/block/core/abi/v1 (abiv1).
version: v2
plugins:
- local: protoc-gen-go
out: .
opt:
- paths=source_relative

21
abi/buf.yaml Normal file
View File

@ -0,0 +1,21 @@
# Buf module for the wasm plugin ABI (WO-WZ-001).
#
# Deliberately separate from the repo-root buf.yaml: that one wraps the
# proto/ git submodule (shared block/proto service contracts), while this
# ABI is SDK-internal and versions in lockstep with the guest shim, so it
# lives repo-local under abi/proto. Run buf from this directory (`make abi`
# at the repo root).
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
# The WO/spec fix the schema path at abi/proto/v1/ (module-relative v1/)
# while the proto package is abi.v1, so the package↔directory rule
# cannot hold without an abi/proto/abi/v1 stutter.
- PACKAGE_DIRECTORY_MATCH
breaking:
use:
- FILE

View File

@ -0,0 +1,541 @@
// capability.proto guesthost capability calls (WO-WZ-001).
//
// Each CoreServices interface (core/plugin/deps.go) becomes a host-function
// family; every interface method gets one request/response message pair
// here, mirroring the Go signature 1:1. UUIDs travel as canonical strings;
// map[string]any / []byte-JSON values travel as JSON bytes.
//
// Calls cross via the generic HostCallRequest/HostCallResponse envelope; the
// method string selects the pair (e.g. "content.get_author_profile"
// ContentGetAuthorProfileRequest/Response). Full method table in
// core/docs/wasm-abi.md.
//
// Not represented here by design:
// - CoreServices.Pool db.proto (the DB driver messages)
// - CoreServices.Interceptors host-side only (never crosses)
// - CoreServices.AppURL/MediaPath LoadRequest.host_config (invoke.proto)
// - CoreServices.CoreServiceBindings manifest core_service_bindings
// - RAGService.RegisterContentFetcher manifest rag_content_fetcher_types
// + the RAG_FETCH hook (callback inversion)
// - ai.ToolDefinition.Handler hostguest tool execution is a runtime-WO
// concern (flagged in core/docs/wasm-abi.md)
syntax = "proto3";
package abi.v1;
import "google/protobuf/timestamp.proto";
import "v1/invoke.proto";
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// HostCallRequest is the generic guesthost capability envelope.
message HostCallRequest {
// Capability method, "<family>.<method>" (e.g. "crypto.encrypt_secret").
string method = 1;
// Serialized family request message.
bytes payload = 2;
}
// HostCallResponse is the generic hostguest capability return envelope.
message HostCallResponse {
// Serialized family response message; empty when error is set.
bytes payload = 1;
// Set when the capability call failed; the guest SDK surfaces it as a
// normal Go error.
AbiError error = 2;
}
// --- content.* (content.Content) ---
message ContentGetAuthorProfileRequest {
string id = 1; // UUID
}
message ContentGetAuthorProfileResponse {
AuthorProfile author = 1;
}
// AuthorProfile mirrors content.AuthorProfile.
message AuthorProfile {
string id = 1; // UUID
string name = 2;
string slug = 3;
string bio = 4;
string avatar_url = 5;
string website = 6;
map<string, string> social_links = 7;
}
message ContentGetPageRequest {
string slug = 1;
}
message ContentGetPageResponse {
PageInfo page = 1;
}
// PageInfo mirrors content.PageInfo.
message PageInfo {
string id = 1; // UUID
string slug = 2;
string title = 3;
}
message ContentGetPostRequest {
string slug = 1;
}
message ContentGetPostResponse {
PostInfo post = 1;
}
// PostInfo mirrors content.PostInfo.
message PostInfo {
string id = 1; // UUID
string slug = 2;
string title = 3;
string excerpt = 4;
string featured_image_url = 5;
string author_id = 6; // UUID
}
message ContentSlugifyRequest {
string text = 1;
}
message ContentSlugifyResponse {
string slug = 1;
}
message ContentBlockNoteToHtmlRequest {
// JSON encoding of the BlockNote document map.
bytes doc_json = 1;
}
message ContentBlockNoteToHtmlResponse {
string html = 1;
}
message ContentGenerateExcerptRequest {
string html = 1;
int32 max_len = 2;
}
message ContentGenerateExcerptResponse {
string excerpt = 1;
}
message ContentStripHtmlRequest {
string html = 1;
}
message ContentStripHtmlResponse {
string text = 1;
}
// --- settings.* (settings.Settings) + settings.update (settings.Updater) ---
message SettingsGetSiteSettingsRequest {}
message SettingsGetSiteSettingsResponse {
// JSON encoding of the site settings map.
bytes settings_json = 1;
}
message SettingsGetPluginSettingsRequest {
string plugin_name = 1;
}
message SettingsGetPluginSettingsResponse {
// JSON encoding of the plugin settings map.
bytes settings_json = 1;
}
message SettingsUpdateSiteSettingRequest {
string key = 1;
// JSON encoding of the value.
bytes value_json = 2;
}
message SettingsUpdateSiteSettingResponse {}
// --- gating.* (gating.Gating) ---
message GatingGetSubscriberTierLevelRequest {
string user_id = 1; // UUID
}
message GatingGetSubscriberTierLevelResponse {
int32 level = 1;
}
message GatingEvaluateAccessRequest {
int32 user_tier_level = 1;
// Absent rule means "no rule" (access granted).
AccessRule rule = 2;
}
message GatingEvaluateAccessResponse {
AccessResult result = 1;
}
// AccessRule mirrors gating.AccessRule.
message AccessRule {
int32 min_tier_level = 1;
string override_tier_id = 2;
string teaser_mode = 3; // "hard", "soft", "none"
int32 teaser_percent = 4;
}
// AccessResult mirrors gating.AccessResult.
message AccessResult {
bool has_access = 1;
string teaser_mode = 2;
int32 teaser_percent = 3;
int32 required_level = 4;
}
// --- crypto.* (crypto.Crypto) ---
message CryptoEncryptSecretRequest {
string plaintext = 1;
}
message CryptoEncryptSecretResponse {
string ciphertext = 1;
}
message CryptoDecryptSecretRequest {
string ciphertext = 1;
}
message CryptoDecryptSecretResponse {
string plaintext = 1;
}
// --- menus.* (menus.Menus) ---
message MenusGetMenuByNameRequest {
string name = 1;
}
message MenusGetMenuByNameResponse {
Menu menu = 1;
}
// Menu mirrors menus.Menu.
message Menu {
string id = 1; // UUID
string name = 2;
}
message MenusGetMenuItemsRequest {
string menu_id = 1; // UUID
}
message MenusGetMenuItemsResponse {
repeated MenuItem items = 1;
}
// MenuItem mirrors menus.MenuItem.
message MenuItem {
string id = 1; // UUID
string menu_id = 2; // UUID
string label = 3;
string url = 4;
string page_slug = 5;
optional string parent_id = 6; // UUID; unset mirrors nil *uuid.UUID
int32 sort_order = 7;
bool open_in_new_tab = 8;
string css_class = 9;
string item_type = 10;
string icon = 11;
}
// --- datasources.* (datasources.Datasources) ---
message DatasourcesResolveBucketRequest {
string bucket_id = 1; // UUID
}
message DatasourcesResolveBucketResponse {
DatasourceResult result = 1;
}
message DatasourcesResolveBucketByKeyRequest {
string bucket_key = 1;
}
message DatasourcesResolveBucketByKeyResponse {
DatasourceResult result = 1;
}
// DatasourceResult mirrors datasources.Result ([]any / map[string]any as
// JSON bytes).
message DatasourceResult {
// JSON array of items.
bytes items_json = 1;
int32 total = 2;
// JSON object; empty when absent.
bytes meta_json = 3;
}
// --- users.* (auth.PublicUsers) ---
message UsersGetByUsernameRequest {
string username = 1;
}
message UsersGetByUsernameResponse {
PublicUserProfile user = 1;
}
message UsersGetByIdRequest {
string id = 1; // UUID
}
message UsersGetByIdResponse {
PublicUserProfile user = 1;
}
// PublicUserProfile mirrors auth.PublicUserProfile.
message PublicUserProfile {
string id = 1; // UUID
string email = 2;
string username = 3;
string display_name = 4;
string avatar_url = 5;
string bio = 6;
bool email_verified = 7;
string role = 8;
}
// --- subscriptions.* (subscriptions.Subscriptions) ---
message SubscriptionsGetUserTierLevelRequest {
string user_id = 1; // UUID
}
message SubscriptionsGetUserTierLevelResponse {
TierLevel tier_level = 1;
}
// TierLevel mirrors subscriptions.TierLevel.
message TierLevel {
int32 level = 1;
// JSON feature payload.
bytes features = 2;
}
message SubscriptionsGetTierBySlugRequest {
string slug = 1;
}
message SubscriptionsGetTierBySlugResponse {
Tier tier = 1;
}
// Tier mirrors subscriptions.Tier.
message Tier {
string id = 1; // UUID
string name = 2;
string slug = 3;
int32 level = 4;
string description = 5;
// JSON feature payload.
bytes features = 6;
bool is_default = 7;
int32 position = 8;
}
message SubscriptionsListTiersRequest {}
message SubscriptionsListTiersResponse {
repeated Tier tiers = 1;
}
message SubscriptionsListActivePlansRequest {
string tier_id = 1; // UUID
}
message SubscriptionsListActivePlansResponse {
repeated Plan plans = 1;
}
// Plan mirrors subscriptions.Plan.
message Plan {
string id = 1; // UUID
string tier_id = 2; // UUID
string billing_interval = 3;
int32 amount = 4;
string currency = 5;
bool is_active = 6;
google.protobuf.Timestamp created_at = 7;
}
// --- media.deposit (plugin.Media) ---
// MediaDepositRequest mirrors plugin.MediaDeposit.
message MediaDepositRequest {
// Optional deterministic media row ID (UUID); empty = host generates one.
string id = 1;
string filename = 2;
bytes data = 3;
string alt_text = 4;
string folder = 5;
string source = 6;
}
// MediaDepositResponse mirrors plugin.MediaResult.
message MediaDepositResponse {
string id = 1; // UUID
// Ready-to-use reference ("media:<uuid>").
string ref = 2;
bool created = 3;
}
// --- email.send (plugin.EmailSender) ---
message EmailSendRequest {
string to = 1;
string subject = 2;
string body = 3;
}
message EmailSendResponse {}
// --- ai.text_call (CoreServices.AITextCall) ---
message AiTextCallRequest {
string task_key = 1;
string system_prompt = 2;
string user_message = 3;
}
message AiTextCallResponse {
string text = 1;
}
// --- ai.tools.* (ai.ToolRegistry) ---
// AiToolRegisterRequest mirrors ai.ToolDefinition (minus Handler, which
// stays guest-side; execution direction is a runtime-WO concern).
message AiToolRegisterRequest {
string slug = 1;
string name = 2;
string description = 3;
// JSON encoding of the parameter schema map.
bytes parameter_schema_json = 4;
}
message AiToolRegisterResponse {}
// --- bridge.* (plugin.PluginBridge) ---
//
// The bridge shares in-process Go values today; across sandboxes only the
// registration/lookup surface serializes. Typed cross-plugin calls need a
// runtime design (see core/docs/wasm-abi.md, open items).
message BridgeRegisterServiceRequest {
string plugin_name = 1;
string service_name = 2;
}
message BridgeRegisterServiceResponse {}
message BridgeGetServiceRequest {
string plugin_name = 1;
string service_name = 2;
}
message BridgeGetServiceResponse {
bool available = 1;
}
// --- jobs.submit (plugin.JobRunner) ---
message JobsSubmitRequest {
string job_type = 1;
// JSON job configuration.
bytes config_json = 2;
}
message JobsSubmitResponse {}
// --- embeddings.* (plugin.EmbeddingService) ---
message EmbeddingsGenerateEmbeddingRequest {
string text = 1;
}
message EmbeddingsGenerateEmbeddingResponse {
repeated float embedding = 1;
}
message EmbeddingsEmbedContentRequest {
string source_type = 1;
string source_id = 2; // UUID
string text = 3;
}
message EmbeddingsEmbedContentResponse {
bool embedded = 1;
}
message EmbeddingsIsAvailableRequest {}
message EmbeddingsIsAvailableResponse {
bool available = 1;
}
// --- rag.* (plugin.RAGService) ---
message RagQueryRequest {
string query = 1;
int32 limit = 2;
}
message RagQueryResponse {
repeated RagResult results = 1;
}
// RagResult mirrors plugin.RAGResult.
message RagResult {
string content = 1;
double score = 2;
map<string, string> metadata = 3;
}
message RagOnContentChangedRequest {
string content_type = 1;
string content_id = 2; // UUID
}
message RagOnContentChangedResponse {}
// --- reviews.* (plugin.ReviewSubmitter) ---
// ReviewsSubmitReviewRequest mirrors plugin.SubmitReviewParams.
message ReviewsSubmitReviewRequest {
string table_id = 1; // UUID
string row_id = 2; // UUID
int32 overall_rating = 3;
string review_text = 4;
// JSON encoding of the per-criterion ratings map.
bytes ratings_json = 5;
repeated string photos = 6;
}
message ReviewsSubmitReviewResponse {
string review_id = 1;
}
// --- badges.refresh (plugin.BadgeRefresher) ---
message BadgesRefreshBadgesRequest {
string table_id = 1; // UUID
string row_id = 2; // UUID
}
message BadgesRefreshBadgesResponse {}

108
abi/proto/v1/db.proto Normal file
View File

@ -0,0 +1,108 @@
// db.proto DB driver messages (WO-WZ-001).
//
// The guest SDK ships a database/sql driver that marshals query text + args
// out through these messages and rows back, so sqlc-generated plugin code
// works unchanged. The host executes on a connection under the per-plugin
// Postgres role. Transactions map to a host-side handle held per guest call
// chain, with a hard deadline so a guest can never pin a connection.
syntax = "proto3";
package abi.v1;
import "google/protobuf/timestamp.proto";
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// DbValue is one pgx-mappable parameter or column value.
message DbValue {
oneof kind {
// SQL NULL (the bool carries no information; true by convention).
bool null = 1;
bool bool_value = 2;
int64 int64_value = 3;
double float64_value = 4;
string string_value = 5;
bytes bytes_value = 6;
google.protobuf.Timestamp timestamp_value = 7;
// UUID in canonical string form.
string uuid_value = 8;
// JSON/JSONB payload bytes.
bytes jsonb_value = 9;
// NUMERIC in decimal string form (lossless).
string numeric_value = 10;
// text[] array.
TextArray text_array_value = 11;
}
}
// TextArray is a Postgres text[] value.
message TextArray {
repeated string values = 1;
}
// DbRow is one result row; values align with DbRowsResponse.columns.
message DbRow {
repeated DbValue values = 1;
}
// DbError carries a database failure back to the guest driver.
message DbError {
// Postgres SQLSTATE when available (e.g. "23505"); empty otherwise.
string code = 1;
string message = 2;
}
// DbQueryRequest executes a rows-returning statement.
message DbQueryRequest {
string sql = 1;
repeated DbValue args = 2;
// Transaction handle from DbTxBeginResponse; 0 = no transaction
// (autocommit).
uint64 tx_handle = 3;
}
// DbRowsResponse returns the full buffered result set.
message DbRowsResponse {
repeated string columns = 1;
repeated DbRow rows = 2;
DbError error = 3;
}
// DbExecRequest executes a statement without returning rows.
message DbExecRequest {
string sql = 1;
repeated DbValue args = 2;
// Transaction handle from DbTxBeginResponse; 0 = no transaction.
uint64 tx_handle = 3;
}
message DbExecResponse {
int64 rows_affected = 1;
DbError error = 2;
}
// DbTxBeginRequest opens a host-side transaction for this call chain.
message DbTxBeginRequest {}
message DbTxBeginResponse {
// Opaque handle referencing the host-side transaction; never 0 on success.
uint64 tx_handle = 1;
DbError error = 2;
}
message DbTxCommitRequest {
uint64 tx_handle = 1;
}
message DbTxCommitResponse {
DbError error = 1;
}
message DbTxRollbackRequest {
uint64 tx_handle = 1;
}
message DbTxRollbackResponse {
DbError error = 1;
}

37
abi/proto/v1/http.proto Normal file
View File

@ -0,0 +1,37 @@
// http.proto buffered HTTP request/response payloads for the HANDLE_HTTP
// hook (WO-WZ-001).
//
// v1 buffers full bodies: no streaming, SSE, or WebSockets inside plugins
// (per the wasm migration design spec §5). The guest runs its real
// chi/connect mux internally and answers one HttpRequest with one
// HttpResponse.
syntax = "proto3";
package abi.v1;
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// HttpRequest is a fully buffered HTTP request forwarded to the guest mux.
message HttpRequest {
string method = 1;
// Request path (no scheme/host/query).
string path = 2;
// Raw query string (without the leading '?').
string raw_query = 3;
// Canonical header name values.
map<string, HeaderValues> headers = 4;
bytes body = 5;
}
// HeaderValues holds the values of one multi-valued HTTP header.
message HeaderValues {
repeated string values = 1;
}
// HttpResponse is the guest's fully buffered response.
message HttpResponse {
int32 status = 1;
map<string, HeaderValues> headers = 2;
bytes body = 3;
}

189
abi/proto/v1/invoke.proto Normal file
View File

@ -0,0 +1,189 @@
// invoke.proto the bn_invoke envelope, hook catalog, and the job/lifecycle
// hook payloads (WO-WZ-001).
//
// Every hostguest call crosses the wasm boundary as one guest export:
//
// bn_invoke(hook_id, ptr, len) packed(ptr, len)
//
// where (ptr, len) frames a serialized InvokeRequest and the packed return
// frames a serialized InvokeResponse. Hook-specific payloads (render.proto,
// http.proto, and the messages below) travel inside InvokeRequest.payload /
// InvokeResponse.payload. See core/docs/wasm-abi.md.
syntax = "proto3";
package abi.v1;
import "v1/manifest.proto";
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// Hook identifies the guest entry point being invoked.
enum Hook {
HOOK_UNSPECIFIED = 0;
// Render one block: payload = RenderBlockRequest / RenderBlockResponse.
HOOK_RENDER_BLOCK = 1;
// Render one template: payload = RenderTemplateRequest / RenderTemplateResponse.
HOOK_RENDER_TEMPLATE = 2;
// Forward a buffered HTTP request to the guest's mux:
// payload = HttpRequest / HttpResponse.
HOOK_HANDLE_HTTP = 3;
// Run a background job handler: payload = JobRequest / JobResponse.
HOOK_JOB = 4;
// Plugin load lifecycle: payload = LoadRequest / LoadResponse.
HOOK_LOAD = 5;
// Plugin unload lifecycle: payload = UnloadRequest / UnloadResponse.
HOOK_UNLOAD = 6;
// Re-fetch content for RAG re-indexing:
// payload = RagFetchRequest / RagFetchResponse.
HOOK_RAG_FETCH = 7;
// Media lifecycle event delivery: payload = MediaHookRequest / MediaHookResponse.
HOOK_MEDIA_HOOK = 8;
// Capture the static manifest at publish time:
// payload = DescribeRequest / DescribeResponse.
HOOK_DESCRIBE = 9;
}
// InvokeRequest is the hostguest call envelope.
message InvokeRequest {
Hook hook = 1;
// Serialized hook-specific request message (see Hook value comments).
bytes payload = 2;
// Milliseconds the guest has to answer; the host also enforces this
// deadline on the wasm instance (WithCloseOnContextDone).
int64 deadline_ms = 3;
}
// InvokeResponse is the guesthost return envelope.
message InvokeResponse {
// Serialized hook-specific response message; empty when error is set.
bytes payload = 1;
// Set when the hook failed; the host treats decode failures and traps as
// implicit ABI_ERROR_CODE_INTERNAL.
AbiError error = 2;
}
// AbiErrorCode classifies boundary-crossing failures.
enum AbiErrorCode {
ABI_ERROR_CODE_UNSPECIFIED = 0;
// The handler ran and failed; message carries the Go error text.
ABI_ERROR_CODE_INTERNAL = 1;
// The payload could not be decoded.
ABI_ERROR_CODE_DECODE = 2;
// The hook/capability is not implemented by the callee.
ABI_ERROR_CODE_UNIMPLEMENTED = 3;
// The call exceeded its deadline; the instance is considered poisoned.
ABI_ERROR_CODE_DEADLINE_EXCEEDED = 4;
// The caller is not entitled to this capability.
ABI_ERROR_CODE_PERMISSION_DENIED = 5;
}
// AbiError is the structured error carried by InvokeResponse and
// HostCallResponse.
message AbiError {
AbiErrorCode code = 1;
string message = 2;
}
// --- DESCRIBE ---
// DescribeRequest asks the guest for its static manifest (publish time only).
message DescribeRequest {
// ABI major version of the calling host/publish tool.
uint32 host_abi_version = 1;
}
// DescribeResponse returns the static manifest.
message DescribeResponse {
PluginManifest manifest = 1;
}
// --- LOAD / UNLOAD ---
// LoadRequest carries the static host configuration the .so world exposed as
// CoreServices.AppURL / CoreServices.MediaPath.
message LoadRequest {
HostConfig host_config = 1;
}
// HostConfig mirrors the plain-value CoreServices fields.
message HostConfig {
string app_url = 1; // CoreServices.AppURL
string media_path = 2; // CoreServices.MediaPath
}
message LoadResponse {}
message UnloadRequest {}
message UnloadResponse {}
// --- JOB ---
// JobRequest dispatches one background job to the guest handler registered
// for job_type (manifest.job_types).
message JobRequest {
string job_type = 1;
// JSON job configuration (json.RawMessage in JobHandlerFunc).
bytes config_json = 2;
}
// JobResponse returns the handler's JSON result.
message JobResponse {
bytes result_json = 1;
}
// --- RAG_FETCH ---
// RagFetchRequest asks the guest's registered content fetcher
// (manifest.rag_content_fetcher_types) for a content item's text.
message RagFetchRequest {
string content_type = 1;
string content_id = 2; // UUID
}
// RagFetchResponse mirrors plugin.ContentFetcher's return values.
message RagFetchResponse {
string title = 1;
string text = 2;
}
// --- MEDIA_HOOK ---
// MediaHookRequest delivers one media lifecycle event
// (plugin.MediaHooksProvider).
message MediaHookRequest {
oneof event {
MediaAnalyzedEvent media_analyzed = 1; // OnMediaAnalyzed
ModerationDecisionEvent moderation_decision = 2; // OnModerationDecision
}
}
message MediaHookResponse {}
// MediaAnalyzedEvent mirrors plugin.MediaAnalyzedEvent (UUIDs as strings).
message MediaAnalyzedEvent {
string media_id = 1;
string analysis_id = 2;
string content_hash = 3;
string status = 4;
string source_plugin = 5;
string source_type = 6;
string source_ref_id = 7;
string safe_adult = 8;
string safe_violence = 9;
string safe_racy = 10;
}
// ModerationDecisionEvent mirrors plugin.ModerationDecisionEvent.
message ModerationDecisionEvent {
string media_id = 1;
string analysis_id = 2;
string status = 3;
string previous_status = 4;
string source_plugin = 5;
string source_type = 6;
string source_ref_id = 7;
string moderated_by = 8;
string note = 9;
}

215
abi/proto/v1/manifest.proto Normal file
View File

@ -0,0 +1,215 @@
// manifest.proto the static plugin manifest (WO-WZ-001).
//
// PluginManifest is the wire form of everything that is *static data* in
// core/plugin/registration.go (PluginRegistration). It is produced once at
// publish time by calling the guest's DESCRIBE hook and stored as manifest.pb
// inside the .bnp artifact; the CMS loader reads it without instantiating the
// wasm module.
//
// Function-valued registration fields cannot cross the wire; they map to
// either a hook (Load/Unload/JobHandlers/MediaHooks/HTTPHandler), a boolean
// presence flag here, or an artifact directory (Assets assets/,
// Schemas schemas/, Migrations migrations/). See core/docs/wasm-abi.md
// for the full field-by-field mapping.
syntax = "proto3";
package abi.v1;
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// PluginManifest mirrors the static surface of plugin.PluginRegistration.
message PluginManifest {
// ABI major version the plugin was built against. The host rejects
// manifests whose major version it does not support.
uint32 abi_version = 1;
// PluginRegistration.Name / .Version.
string name = 2;
string version = 3;
// PluginRegistration.Dependencies.
repeated Dependency dependencies = 4;
// Blocks registered via BlockRegistry.Register (captured by DESCRIBE).
repeated BlockMeta blocks = 5;
// Blocks registered via BlockRegistry.RegisterTemplateOverride[WithSource].
repeated BlockTemplateOverride block_template_overrides = 6;
// Templates registered via TemplateRegistry (captured by DESCRIBE).
repeated string template_keys = 7; // TemplateRegistry.Register
repeated SystemTemplateMeta system_templates = 8; // RegisterSystemTemplate
repeated PageTemplateMeta page_templates = 9; // RegisterPageTemplate
repeated string email_wrapper_system_keys = 10; // RegisterEmailWrapper
// PluginRegistration.AdminPages.
repeated AdminPage admin_pages = 11;
// PluginRegistration.SettingsSchema / .ThemePresets / .BundledFonts (JSON).
bytes settings_schema = 12;
bytes theme_presets = 13;
bytes bundled_fonts = 14;
// PluginRegistration.MasterPages.
repeated MasterPageDefinition master_pages = 15;
// PluginRegistration.AIActions.
repeated AiAction ai_actions = 16;
// RBAC roles for the plugin's own Connect services, merged from
// ServiceRegistration (full method name role, e.g.
// "/symposium.v1.ForumService/CreateThread" "admin").
map<string, string> rbac_method_roles = 17;
// PluginRegistration.CSSManifest.
CssManifest css_manifest = 18;
// PluginRegistration.RequiredIconPacks.
repeated string required_icon_packs = 19;
// PluginRegistration.DirectoryExtensions (static fields only; the
// panel-section / pin-decorator callbacks are counted so the host knows
// how many guest callbacks exist).
DirectoryExtensions directory_extensions = 20;
// Job types the plugin handles (keys of PluginRegistration.JobHandlers).
// The host dispatches these via the JOB hook.
repeated string job_types = 21;
// Content types the plugin registered RAG content fetchers for
// (RAGService.RegisterContentFetcher inverts to a manifest declaration;
// the host calls back via the RAG_FETCH hook).
repeated string rag_content_fetcher_types = 22;
// PluginRegistration.SettingsPanel Module Federation path of the
// settings panel component ("" when the plugin has none).
string settings_panel = 23;
// Presence flags for function-valued registration fields that invert to
// hooks at runtime.
bool has_http_handler = 24; // PluginRegistration.HTTPHandler HANDLE_HTTP
bool has_load_hook = 25; // PluginRegistration.Load LOAD
bool has_unload_hook = 26; // PluginRegistration.Unload UNLOAD
bool has_media_hooks = 27; // PluginRegistration.MediaHooks MEDIA_HOOK
bool has_provisioner = 28; // PluginRegistration.RegisterWithProvisioner
// Core CMS services the plugin mounts with custom RBAC roles
// (CoreServiceBindings.Bind becomes a static declaration; the host
// constructs and mounts the handlers).
repeated CoreServiceBinding core_service_bindings = 29;
}
// Dependency mirrors plugin.Dependency.
message Dependency {
string plugin = 1;
string min_version = 2;
bool required = 3;
}
// BlockMeta mirrors blocks.BlockMeta. Source is omitted: the host assigns it
// from the manifest's plugin name at load time.
message BlockMeta {
string key = 1;
string title = 2;
string description = 3;
// blocks.BlockCategory string ("content", "layout", "navigation", "blog",
// "theme").
string category = 4;
bool has_internal_slot = 5;
bool hidden = 6;
string editor_js = 7;
}
// BlockTemplateOverride captures BlockRegistry.RegisterTemplateOverride and
// RegisterTemplateOverrideWithSource registrations.
message BlockTemplateOverride {
string template_key = 1;
string block_key = 2;
// Override source label; empty for plain RegisterTemplateOverride.
string source = 3;
}
// SystemTemplateMeta mirrors templates.SystemTemplateMeta.
message SystemTemplateMeta {
string key = 1;
string title = 2;
string description = 3;
}
// PageTemplateMeta mirrors templates.PageTemplateMeta plus the system
// template it was registered under.
message PageTemplateMeta {
string system_key = 1;
string key = 2;
string title = 3;
string description = 4;
repeated string slots = 5;
}
// AdminPage mirrors plugin.AdminPage.
message AdminPage {
string key = 1;
string title = 2;
string icon = 3;
string route = 4;
}
// AiAction mirrors plugin.AIAction.
message AiAction {
string key = 1;
string title = 2;
string description = 3;
string default_provider = 4;
string default_model = 5;
}
// CssManifest mirrors plugin.CSSManifest.
message CssManifest {
map<string, string> npm_packages = 1;
repeated string css_directives = 2;
string input_css_append = 3;
}
// DirectoryExtensions mirrors the static fields of plugin.DirectoryExtensions.
message DirectoryExtensions {
repeated string boolean_filter_fields = 1;
repeated string select_filter_fields = 2;
map<string, BadgeLabel> badge_labels = 3;
// Counts of the callback slices (PanelSections / PinDecorators) so the host
// knows how many guest callbacks the plugin registered. Their invocation
// hook is a runtime-WO concern.
uint32 panel_section_count = 4;
uint32 pin_decorator_count = 5;
}
// BadgeLabel mirrors the [2]string value of DirectoryExtensions.BadgeLabels.
message BadgeLabel {
string positive = 1;
string negative = 2;
}
// MasterPageDefinition mirrors plugin.MasterPageDefinition.
message MasterPageDefinition {
string key = 1;
string title = 2;
repeated string page_templates = 3;
repeated MasterPageBlock blocks = 4;
}
// MasterPageBlock mirrors plugin.MasterPageBlock.
message MasterPageBlock {
string block_key = 1;
string title = 2;
// JSON encoding of the block's content map.
bytes content_json = 3;
optional string html_content = 4;
string slot = 5;
int32 sort_order = 6;
}
// CoreServiceBinding mirrors a plugin.CoreServiceBindings.Bind call: mount
// the named core-provided Connect service with these RBAC roles.
message CoreServiceBinding {
string service_name = 1;
map<string, string> method_roles = 2;
}

200
abi/proto/v1/render.proto Normal file
View File

@ -0,0 +1,200 @@
// render.proto block/template render payloads and the explicit
// render-context envelope (WO-WZ-001).
//
// RenderContext serializes every value blocks currently read from ctx via
// core/blocks/context.go. Function-valued context entries (SlotRenderer,
// MediaResolver, EmbedResolver, the generic Queries value) cannot cross the
// wire; see core/docs/wasm-abi.md for how each one maps.
syntax = "proto3";
package abi.v1;
import "google/protobuf/timestamp.proto";
option go_package = "git.dev.alexdunmow.com/block/core/abi/v1;abiv1";
// RenderBlockRequest renders one block (blocks.BlockFunc).
message RenderBlockRequest {
string block_key = 1;
// JSON encoding of the block's content map.
bytes content_json = 2;
RenderContext render_context = 3;
}
message RenderBlockResponse {
string html = 1;
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
message RenderTemplateRequest {
string template_key = 1;
// JSON encoding of the template's doc map (the page document).
bytes doc_json = 2;
RenderContext render_context = 3;
}
message RenderTemplateResponse {
bytes html = 1;
}
// RenderContext is the explicit envelope of the context values enumerated
// from core/blocks/context.go. Absent sub-messages mean the corresponding
// ctx value was not set (the getters return nil / zero values).
message RenderContext {
// blocks.GetRequest subset of *http.Request that render code reads.
RequestInfo request = 1;
// blocks.GetBlockContext the pongo2 template data struct.
BlockContext block_context = 2;
// blocks.GetTemplateKey.
string template_key = 3;
// blocks.GetCurrentPage.
PageContext page = 4;
// blocks.GetCurrentBlogPost.
PostContext post = 5;
// blocks.GetCurrentAuthor.
AuthorContext author = 6;
// blocks.GetCurrentCategory.
CategoryContext category = 7;
// blocks.GetMasterPage; presence doubles as IsMasterPageContext.
MasterPageContext master_page = 8;
// blocks.GetRequestedPath (404 pages).
string requested_path = 9;
// blocks.GetInjectedSlots (master page rendering).
map<string, string> injected_slots = 10;
// blocks.IsEditor.
bool is_editor = 11;
// blocks.GetExpectedSlots.
repeated string expected_slots = 12;
// blocks.GetBlockID (UUID; empty for uuid.Nil).
string block_id = 13;
// blocks.GetCurrentPageID (UUID; empty for uuid.Nil).
string current_page_id = 14;
// blocks.GetHumanProofBanner.
HumanProofBanner human_proof_banner = 15;
// blocks.GetDetailRow.
DetailRow detail_row = 16;
// Active theme variables, JSON-encoded.
bytes theme_json = 17;
// Site locale (BCP 47).
string locale = 18;
}
// RequestInfo carries the request subset render code reads from
// blocks.GetRequest (single-valued header map, matching BlockContext).
message RequestInfo {
string method = 1;
string url = 2;
string path = 3;
string host = 4;
string raw_query = 5;
map<string, string> headers = 6;
map<string, string> cookies = 7;
string remote_ip = 8;
string referrer = 9;
string user_agent = 10;
}
// PageContext mirrors blocks.PageContext.
message PageContext {
string id = 1; // UUID
string slug = 2;
string title = 3;
string post_type = 4; // "page", "post", "master", "system"
string status = 5; // "published", "draft", "scheduled"
}
// PostContext mirrors blocks.PostContext.
message PostContext {
string id = 1; // UUID
string slug = 2;
string title = 3;
string excerpt = 4;
string featured_image_url = 5;
string author_id = 6; // UUID
google.protobuf.Timestamp published_at = 7;
int32 reading_time = 8;
bool is_featured = 9;
}
// AuthorContext mirrors blocks.AuthorContext.
message AuthorContext {
string id = 1; // UUID
string name = 2;
string slug = 3;
string bio = 4;
string avatar_url = 5;
}
// CategoryContext mirrors blocks.CategoryContext.
message CategoryContext {
string id = 1; // UUID
string name = 2;
string slug = 3;
}
// MasterPageContext mirrors blocks.MasterPageContext.
message MasterPageContext {
string id = 1; // UUID
string slug = 2;
string title = 3;
}
// HumanProofBanner mirrors blocks.HumanProofBannerData.
message HumanProofBanner {
int32 active_time_minutes = 1;
int32 keystroke_count = 2;
int32 session_count = 3;
string post_slug = 4;
}
// DetailRow mirrors blocks.DetailRowInfo.
message DetailRow {
string table_id = 1;
string row_id = 2;
// JSON encoding of the row data map.
bytes data_json = 3;
}
// BlockContext mirrors blocks.BlockContext (the pongo2 data struct) 1:1.
// map[string]any fields travel as JSON bytes.
message BlockContext {
string url = 1;
string path = 2;
string slug = 3;
string page_id = 4;
string page_title = 5;
string template_key = 6;
bool is_editor = 7;
int64 timestamp = 8;
google.protobuf.Timestamp now = 9;
bool is_logged_in = 10;
string user_id = 11;
string user_email = 12;
string user_role = 13;
string method = 14;
string host = 15;
map<string, string> query = 16;
string referrer = 17;
string user_agent = 18;
string ip = 19;
map<string, string> cookies = 20;
map<string, string> headers = 21;
string country = 22;
string city = 23;
string timezone = 24;
bytes current_author_json = 25;
bytes current_post_json = 26;
bytes current_category_json = 27;
bytes site_json = 28;
bool is_public_logged_in = 29;
string public_user_id = 30;
string public_username = 31;
string public_display_name = 32;
bool public_email_verified = 33;
string detail_row_id = 34;
string detail_table_id = 35;
bytes detail_row_data_json = 36;
string blog_index_url = 37;
string category_page_url = 38;
}

5167
abi/v1/capability.pb.go Normal file

File diff suppressed because it is too large Load Diff

1056
abi/v1/db.pb.go Normal file

File diff suppressed because it is too large Load Diff

296
abi/v1/http.pb.go Normal file
View File

@ -0,0 +1,296 @@
// http.proto — buffered HTTP request/response payloads for the HANDLE_HTTP
// hook (WO-WZ-001).
//
// v1 buffers full bodies: no streaming, SSE, or WebSockets inside plugins
// (per the wasm migration design spec §5). The guest runs its real
// chi/connect mux internally and answers one HttpRequest with one
// HttpResponse.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: v1/http.proto
package abiv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// HttpRequest is a fully buffered HTTP request forwarded to the guest mux.
type HttpRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"`
// Request path (no scheme/host/query).
Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"`
// Raw query string (without the leading '?').
RawQuery string `protobuf:"bytes,3,opt,name=raw_query,json=rawQuery,proto3" json:"raw_query,omitempty"`
// Canonical header name → values.
Headers map[string]*HeaderValues `protobuf:"bytes,4,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Body []byte `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HttpRequest) Reset() {
*x = HttpRequest{}
mi := &file_v1_http_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HttpRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HttpRequest) ProtoMessage() {}
func (x *HttpRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_http_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HttpRequest.ProtoReflect.Descriptor instead.
func (*HttpRequest) Descriptor() ([]byte, []int) {
return file_v1_http_proto_rawDescGZIP(), []int{0}
}
func (x *HttpRequest) GetMethod() string {
if x != nil {
return x.Method
}
return ""
}
func (x *HttpRequest) GetPath() string {
if x != nil {
return x.Path
}
return ""
}
func (x *HttpRequest) GetRawQuery() string {
if x != nil {
return x.RawQuery
}
return ""
}
func (x *HttpRequest) GetHeaders() map[string]*HeaderValues {
if x != nil {
return x.Headers
}
return nil
}
func (x *HttpRequest) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
// HeaderValues holds the values of one multi-valued HTTP header.
type HeaderValues struct {
state protoimpl.MessageState `protogen:"open.v1"`
Values []string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HeaderValues) Reset() {
*x = HeaderValues{}
mi := &file_v1_http_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HeaderValues) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeaderValues) ProtoMessage() {}
func (x *HeaderValues) ProtoReflect() protoreflect.Message {
mi := &file_v1_http_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HeaderValues.ProtoReflect.Descriptor instead.
func (*HeaderValues) Descriptor() ([]byte, []int) {
return file_v1_http_proto_rawDescGZIP(), []int{1}
}
func (x *HeaderValues) GetValues() []string {
if x != nil {
return x.Values
}
return nil
}
// HttpResponse is the guest's fully buffered response.
type HttpResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
Headers map[string]*HeaderValues `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HttpResponse) Reset() {
*x = HttpResponse{}
mi := &file_v1_http_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *HttpResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HttpResponse) ProtoMessage() {}
func (x *HttpResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_http_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use HttpResponse.ProtoReflect.Descriptor instead.
func (*HttpResponse) Descriptor() ([]byte, []int) {
return file_v1_http_proto_rawDescGZIP(), []int{2}
}
func (x *HttpResponse) GetStatus() int32 {
if x != nil {
return x.Status
}
return 0
}
func (x *HttpResponse) GetHeaders() map[string]*HeaderValues {
if x != nil {
return x.Headers
}
return nil
}
func (x *HttpResponse) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
var File_v1_http_proto protoreflect.FileDescriptor
const file_v1_http_proto_rawDesc = "" +
"\n" +
"\rv1/http.proto\x12\x06abi.v1\"\xf8\x01\n" +
"\vHttpRequest\x12\x16\n" +
"\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" +
"\x04path\x18\x02 \x01(\tR\x04path\x12\x1b\n" +
"\traw_query\x18\x03 \x01(\tR\brawQuery\x12:\n" +
"\aheaders\x18\x04 \x03(\v2 .abi.v1.HttpRequest.HeadersEntryR\aheaders\x12\x12\n" +
"\x04body\x18\x05 \x01(\fR\x04body\x1aP\n" +
"\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
"\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01\"&\n" +
"\fHeaderValues\x12\x16\n" +
"\x06values\x18\x01 \x03(\tR\x06values\"\xc9\x01\n" +
"\fHttpResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\x05R\x06status\x12;\n" +
"\aheaders\x18\x02 \x03(\v2!.abi.v1.HttpResponse.HeadersEntryR\aheaders\x12\x12\n" +
"\x04body\x18\x03 \x01(\fR\x04body\x1aP\n" +
"\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12*\n" +
"\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01B0Z.git.dev.alexdunmow.com/block/core/abi/v1;abiv1b\x06proto3"
var (
file_v1_http_proto_rawDescOnce sync.Once
file_v1_http_proto_rawDescData []byte
)
func file_v1_http_proto_rawDescGZIP() []byte {
file_v1_http_proto_rawDescOnce.Do(func() {
file_v1_http_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_v1_http_proto_rawDesc), len(file_v1_http_proto_rawDesc)))
})
return file_v1_http_proto_rawDescData
}
var file_v1_http_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_v1_http_proto_goTypes = []any{
(*HttpRequest)(nil), // 0: abi.v1.HttpRequest
(*HeaderValues)(nil), // 1: abi.v1.HeaderValues
(*HttpResponse)(nil), // 2: abi.v1.HttpResponse
nil, // 3: abi.v1.HttpRequest.HeadersEntry
nil, // 4: abi.v1.HttpResponse.HeadersEntry
}
var file_v1_http_proto_depIdxs = []int32{
3, // 0: abi.v1.HttpRequest.headers:type_name -> abi.v1.HttpRequest.HeadersEntry
4, // 1: abi.v1.HttpResponse.headers:type_name -> abi.v1.HttpResponse.HeadersEntry
1, // 2: abi.v1.HttpRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues
1, // 3: abi.v1.HttpResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues
4, // [4:4] is the sub-list for method output_type
4, // [4:4] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_v1_http_proto_init() }
func file_v1_http_proto_init() {
if File_v1_http_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_http_proto_rawDesc), len(file_v1_http_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_v1_http_proto_goTypes,
DependencyIndexes: file_v1_http_proto_depIdxs,
MessageInfos: file_v1_http_proto_msgTypes,
}.Build()
File_v1_http_proto = out.File
file_v1_http_proto_goTypes = nil
file_v1_http_proto_depIdxs = nil
}

1385
abi/v1/invoke.pb.go Normal file

File diff suppressed because it is too large Load Diff

1450
abi/v1/manifest.pb.go Normal file

File diff suppressed because it is too large Load Diff

1665
abi/v1/render.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1115,11 +1115,11 @@ func gitignoredTrackedWarning(repoDir string, w io.Writer) {
if names == "" {
return
}
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
_, _ = fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
for n := range strings.SplitSeq(names, "\n") {
fmt.Fprintln(w, " "+n)
_, _ = fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
_, _ = fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
}
// untrackedFilesWarning writes a warning to w listing untracked files in
@ -1134,11 +1134,11 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
if names == "" {
return
}
fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
_, _ = fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
for n := range strings.SplitSeq(names, "\n") {
fmt.Fprintln(w, " "+n)
_, _ = fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
_, _ = fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
}
// emitPublishWarnings runs the publish-time warning helpers in the order the
@ -1181,11 +1181,11 @@ func submoduleWarning(repoDir string, w io.Writer) {
if len(paths) == 0 {
return
}
fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
_, _ = fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
for _, p := range paths {
fmt.Fprintln(w, " "+p)
_, _ = fmt.Fprintln(w, " "+p)
}
fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
_, _ = fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
}
// autoCommitPluginMod stages and commits plugin.mod with the given message,

320
docs/wasm-abi.md Normal file
View File

@ -0,0 +1,320 @@
# Wasm Plugin ABI (v1)
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
Design rationale: the wasm plugin migration design spec in the cms repo
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
The `abi/` buf module is deliberately separate from the repo-root buf config:
`proto/` is the shared block/proto git submodule (service API contracts),
while this ABI is SDK-internal and versions in lockstep with the guest shim,
so it lives repo-local.
## Versioning — `abi_version`
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
**major** version the plugin was built against. Current value: **1**.
- The host **rejects** any manifest whose major version it does not support —
at install/publish time (manifest read) and again at `DESCRIBE`
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
newer guest shim can refuse an older host symmetrically).
- Within a major version, evolution is protobuf-additive only: new fields,
new `Hook` values, new capability methods. Removing or renaming anything
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
in `abi/buf.yaml`) enforces this against the previous commit.
## Module lifecycle — REACTOR mode (`_initialize`, no `_start`)
Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for
`go:wasmexport`; this repo's floor is higher — check `go.mod`):
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
```
with one boilerplate main file next to the untouched Registration:
```go
//go:build wasip1
package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) }
func main() {} // never called — reactor mode
```
A reactor module exports `_initialize` instead of `_start`. The host MUST
run `_initialize` exactly once per instance **before any `bn_invoke`**
(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package
init funcs — hence `Serve`, which stores the registration and returns.
`main` exists only to satisfy the linker and is never called.
> Command mode (plain `go build`) does NOT work and must not be used
> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start`
> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go
> deadlock detector and traps, while a returning `main` exits and closes
> the module — either way the exports are never callable. The original
> blocking-main design was amended to reactor mode for this reason.
## Calling convention (ptr+len, packed u64)
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
protobuf bytes in guest linear memory. The guest exports (via
`go:wasmexport`):
| Export | Signature | Purpose |
|---|---|---|
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
Host functions (guest→host capability calls) live in wasm import module
**`blockninja`** and use the same shape in reverse. There is exactly ONE
generic import rather than one symbol per capability family:
```
(blockninja) host_call(ptr: u32, len: u32) → packed: u64
```
The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose
`method` string — `"<family>.<snake_method>"`, including the `db.*` driver
methods — already selects the family); the host returns a packed `u64`
framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`.
The guest shim releases that buffer after decoding, so the host must not
reuse it. Per-family import symbols were considered and rejected (decision,
WO-WZ-002): they would add ~40 declarations on both sides for zero type
safety, since the payloads are opaque protobuf bytes either way.
Buffers MUST come from `bn_alloc` on both paths — the guest rejects a
`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`),
and treats an unknown host-call response pointer the same way.
A `packed` value of `0` means the callee could not even produce an envelope
(allocation failure / trap); the caller treats it as
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
> A logically-empty response is **not** packed `0`. A successful hook whose
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
> `len==0 → return 0` shortcut made every successful empty-response hook —
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
> instance. The cms host in WO-WZ-006 must likewise not conflate a
> zero-length payload with a missing envelope.)
Instances are single-threaded: one `bn_invoke` at a time per instance;
concurrency comes from the per-plugin instance pool.
## Hook catalog (`invoke.proto`)
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}`
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
| Hook | Request / Response | Fires |
|---|---|---|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
## Capability calls (`capability.proto`, `db.proto`)
Guest→host. Envelope: `HostCallRequest{method, payload}`
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
each pair mirrors one Go interface method from `CoreServices` 1:1
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
| Family | Methods | Go surface |
|---|---|---|
| `content` | `get_author_profile`, `get_page`, `get_post`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | `content.Content` |
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting` | `settings.Settings`, `settings.Updater` |
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
| `media` | `deposit` | `plugin.Media` |
| `email` | `send` | `plugin.EmailSender` |
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
| `bridge` | `register_service`, `get_service` | `plugin.PluginBridge` |
| `jobs` | `submit` | `plugin.JobRunner` |
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
The guest half of the SDK implements the existing Go interfaces as stubs
marshaling to these calls (`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:
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
chain's deadline so a guest can never pin a connection. **Guest side
(WO-WZ-004):** `core/plugin/wasmguest/bnwasm` implements this over the
transport as two surfaces — a `database/sql` driver registered as `"bnwasm"`,
and a `plugin.Pool` handing out a `pgx.Tx`-shaped value. The latter is the
primary path: current plugins' sqlc configs use `sql_package: "pgx/v5"`, so
their generated `DBTX` needs `pgconn.CommandTag`/`pgx.Rows`/`pgx.Row` (which
`database/sql` cannot produce), and the `Pool`/`Tx` satisfy it with no source
edits. `DbError` surfaces as `*pgconn.PgError` (SQLSTATE preserved for
`errors.As`). Named args (`pgx.NamedArgs`/`QueryRewriter`) and nested
transactions/savepoints are rejected with clear errors — no fleet plugin uses
either. The DbValue↔Go scan mapping is pinned in the exported
`bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors.
**`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a
repeated string with no per-element NULL, so a Postgres `text[]` like
`{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as
a whole can still be SQL NULL (nil `[]string``DbValue_Null`); only a NULL
*inside* the array is unrepresentable. The host executor must honor this same
limit (encode a NULL element as `""` or reject it), not invent a sentinel.
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
`manifest.rbac_method_roles`.
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
declaration; the host constructs and mounts the handlers.
- `RAGService.RegisterContentFetcher` → static
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
callback inversion.
## Error semantics
`AbiError{code, message}` travels in `InvokeResponse.error` and
`HostCallResponse.error`:
| Code | Meaning | Instance consequence |
|---|---|---|
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
Host-function errors surface to plugin code as ordinary Go errors via the
guest SDK. Repeated instance failures trip the existing
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
sqlc/pgx error handling keeps working.
## Manifest ↔ `PluginRegistration` mapping
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
Field-by-field:
| `PluginRegistration` field | Wire counterpart |
|---|---|
| `Name` | `PluginManifest.name` |
| `Version` | `PluginManifest.version` |
| `Dependencies` | `dependencies` (`Dependency`) |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
| `SettingsSchema` | `settings_schema` (JSON bytes) |
| `ThemePresets` | `theme_presets` (JSON bytes) |
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
| `SettingsPanel` | `settings_panel` |
| `AdminPages` | `admin_pages` (`AdminPage`) |
| `CSSManifest` | `css_manifest` (`CssManifest`) |
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
| `JobHandlers` | `job_types` + `HOOK_JOB` |
| `AIActions` | `ai_actions` (`AiAction`) |
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
| `Load` | `has_load_hook` + `HOOK_LOAD` |
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
| `RequiredIconPacks` | `required_icon_packs` |
## Render context
`RenderContext` (`render.proto`) is the explicit envelope of every value
blocks read from `ctx` today (`core/blocks/context.go`): request info,
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
category/master-page, requested path, injected/expected slots, editor flag,
block + page IDs, human-proof banner, detail row, theme variables JSON, and
locale.
Function-valued context entries cannot serialize; their v1 mapping:
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
- `SlotRenderer` / `MediaResolver` / `EmbedResolver`**open items** (below).
## Open items (flagged for runtime WOs — additive, no major bump needed)
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing
its guest-side `Handler` needs a host→guest hook (e.g. `HOOK_AI_TOOL_CALL`).
Deliberately not added here — the WO fixes the v1 hook catalog.
- **Job progress**: `plugin.JobHandlerFunc`'s `progress(current, total,
message)` callback needs a `jobs.progress` host function.
- **Bridge calls**: `bridge.register_service`/`get_service` cover
registration/lookup only; typed cross-plugin *invocation* (today: shared
in-process Go values) needs a runtime design.
- **Directory extension callbacks**: `panel_section_count` /
`pin_decorator_count` declare the guest callbacks; invoking them needs a
hook.
- **Slot/media/embed resolvers in render**: container-slot rendering and
media/embed resolution during a guest render need host functions (or host-
side pre-rendering into `RenderContext`).

3
go.mod
View File

@ -12,6 +12,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.18.6
github.com/spf13/cobra v1.10.2
github.com/tetratelabs/wazero v1.12.0
golang.org/x/mod v0.34.0
google.golang.org/protobuf v1.36.11
)
@ -28,6 +29,6 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

6
go.sum
View File

@ -62,14 +62,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@ -0,0 +1,283 @@
package bnwasm
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"google.golang.org/protobuf/types/known/timestamppb"
)
// toDbValues marshals a positional argument list to wire DbValues. Named args
// (pgx.NamedArgs, or any pgx.QueryRewriter passed as the sole argument) are
// rejected: sqlc emits positional parameters, and rewriting a query host-side
// is out of scope for v1.
func toDbValues(args []any) ([]*abiv1.DbValue, error) {
if len(args) == 1 {
if _, ok := args[0].(pgx.QueryRewriter); ok {
return nil, fmt.Errorf("bnwasm: named arguments (pgx.QueryRewriter/NamedArgs) are not supported; use positional parameters")
}
}
out := make([]*abiv1.DbValue, len(args))
for i, a := range args {
v, err := toDbValue(a)
if err != nil {
return nil, fmt.Errorf("bnwasm: arg %d: %w", i, err)
}
out[i] = v
}
return out, nil
}
// toDbValue maps one Go query argument to a DbValue. Concrete BlockNinja/pgx
// types (uuid, time, text[], json) are special-cased so they round-trip to
// their dedicated DbValue variant; anything implementing driver.Valuer (pgtype
// scalars, sql.Null*) is normalized through Value(); the rest goes by kind.
func toDbValue(a any) (*abiv1.DbValue, error) {
switch v := a.(type) {
case nil:
return nullValue(), nil
case uuid.UUID:
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.String()}}, nil
case uuid.NullUUID:
if !v.Valid {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.UUID.String()}}, nil
case time.Time:
return &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(v)}}, nil
case json.RawMessage:
if v == nil {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_JsonbValue{JsonbValue: v}}, nil
case []byte:
if v == nil {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_BytesValue{BytesValue: v}}, nil
case []string:
// nil → NULL (matching []byte/json.RawMessage); an empty-but-non-nil
// slice stays a non-NULL empty text[].
if v == nil {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: v}}}, nil
case string:
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: v}}, nil
case bool:
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: v}}, nil
}
// driver.Valuer covers pgtype scalars (Timestamptz, Numeric, Int4, ...) and
// sql.Null*; recurse on the normalized driver.Value.
if valuer, ok := a.(driver.Valuer); ok {
dv, err := valuer.Value()
if err != nil {
return nil, err
}
if dv == nil {
return nullValue(), nil
}
return toDbValue(dv)
}
// Fall back to reflection for pointers and the basic numeric kinds.
rv := reflect.ValueOf(a)
switch rv.Kind() {
case reflect.Pointer:
if rv.IsNil() {
return nullValue(), nil
}
return toDbValue(rv.Elem().Interface())
case reflect.Bool:
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: rv.Bool()}}, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: rv.Int()}}, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: int64(rv.Uint())}}, nil
case reflect.Float32, reflect.Float64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Float64Value{Float64Value: rv.Float()}}, nil
case reflect.String:
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: rv.String()}}, nil
}
return nil, fmt.Errorf("bnwasm: unsupported argument type %T", a)
}
func nullValue() *abiv1.DbValue {
return &abiv1.DbValue{Kind: &abiv1.DbValue_Null{Null: true}}
}
// naturalValue returns the canonical Go scan value for a DbValue and whether it
// is SQL NULL. This is the semantic contract WO-WZ-007 (host executor) mirrors:
//
// null → (nil, true)
// bool → (bool, false)
// int64 → (int64, false)
// float64 → (float64, false)
// string → (string, false)
// bytes → ([]byte, false)
// timestamp → (time.Time,false)
// uuid → (string, false) // canonical form; uuid.UUID.Scan accepts it
// jsonb → ([]byte, false)
// numeric → (string, false) // lossless decimal string
// text_array → ([]string, false)
func naturalValue(dv *abiv1.DbValue) (any, bool) {
switch k := dv.GetKind().(type) {
case *abiv1.DbValue_Null:
return nil, true
case *abiv1.DbValue_BoolValue:
return k.BoolValue, false
case *abiv1.DbValue_Int64Value:
return k.Int64Value, false
case *abiv1.DbValue_Float64Value:
return k.Float64Value, false
case *abiv1.DbValue_StringValue:
return k.StringValue, false
case *abiv1.DbValue_BytesValue:
return k.BytesValue, false
case *abiv1.DbValue_TimestampValue:
return k.TimestampValue.AsTime(), false
case *abiv1.DbValue_UuidValue:
return k.UuidValue, false
case *abiv1.DbValue_JsonbValue:
return []byte(k.JsonbValue), false
case *abiv1.DbValue_NumericValue:
return k.NumericValue, false
case *abiv1.DbValue_TextArrayValue:
return append([]string(nil), k.TextArrayValue.GetValues()...), false
default:
return nil, true
}
}
// scanValue assigns a DbValue into a destination pointer with pgx-flavored Scan
// semantics: a nil dest skips the column; a sql.Scanner destination is fed the
// natural value (including nil for NULL); pointer-to-pointer destinations model
// nullability (set nil on NULL); everything else is assigned by reflection with
// integer/float/string/[]byte/[]string coercion.
func scanValue(dv *abiv1.DbValue, dest any) error {
if dest == nil {
return nil // pgx: nil dest skips the value entirely
}
nv, isNull := naturalValue(dv)
if sc, ok := dest.(sql.Scanner); ok {
return sc.Scan(nv)
}
rv := reflect.ValueOf(dest)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return fmt.Errorf("bnwasm: scan destination must be a non-nil pointer, got %T", dest)
}
elem := rv.Elem()
// Pointer-to-pointer (e.g. **string): nullable column mapped to *string.
if elem.Kind() == reflect.Pointer {
if isNull {
elem.Set(reflect.Zero(elem.Type()))
return nil
}
np := reflect.New(elem.Type().Elem())
if err := assign(np.Elem(), nv); err != nil {
return err
}
elem.Set(np)
return nil
}
if isNull {
elem.Set(reflect.Zero(elem.Type()))
return nil
}
return assign(elem, nv)
}
// assign coerces a natural value into a concrete (non-pointer) destination.
func assign(dst reflect.Value, nv any) error {
src := reflect.ValueOf(nv)
// Direct assignability (string→string, time.Time→time.Time, []string→[]string).
if src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
// Convertibility (json.RawMessage←[]byte, int64→int32, float64→float32,
// named string types, []byte→string, ...).
if src.Type().ConvertibleTo(dst.Type()) {
// Guard against lossy string<->numeric "conversions" reflect allows for
// rune/byte-ish types by only converting between compatible kinds.
if convertibleKinds(src.Kind(), dst.Kind()) {
dst.Set(src.Convert(dst.Type()))
return nil
}
}
return fmt.Errorf("bnwasm: cannot scan %s into %s", src.Type(), dst.Type())
}
func convertibleKinds(src, dst reflect.Kind) bool {
num := func(k reflect.Kind) bool {
switch k {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
}
return false
}
switch {
case num(src) && num(dst):
return true
case src == reflect.String && dst == reflect.String:
return true
case src == reflect.Slice && dst == reflect.Slice: // []byte→json.RawMessage, []string→named
return true
case src == reflect.Slice && dst == reflect.String: // []byte→string
return true
case src == reflect.String && dst == reflect.Slice: // string→[]byte
return true
}
return false
}
// driverValue maps a DbValue to a database/sql driver.Value (the closed set:
// nil, int64, float64, bool, []byte, string, time.Time). uuid/numeric surface
// as strings and text[] as a Postgres array literal string, since driver.Value
// has no richer representation; callers scan those through the column's
// sql.Scanner as usual.
func driverValue(dv *abiv1.DbValue) driver.Value {
nv, isNull := naturalValue(dv)
if isNull {
return nil
}
switch v := nv.(type) {
case []string:
return encodePgTextArray(v)
default:
return v
}
}
// encodePgTextArray renders a []string as a Postgres text[] literal, e.g.
// {"a","b,c"}, quoting/escaping every element for lossless reconstruction.
func encodePgTextArray(vals []string) string {
var b strings.Builder
b.WriteByte('{')
for i, s := range vals {
if i > 0 {
b.WriteByte(',')
}
b.WriteByte('"')
b.WriteString(strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s))
b.WriteByte('"')
}
b.WriteByte('}')
return b.String()
}

View File

@ -0,0 +1,183 @@
package bnwasm
import (
"encoding/json"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/google/uuid"
"google.golang.org/protobuf/types/known/timestamppb"
)
// FixtureTime is the fixed timestamp used by the shared DbValue fixtures, so
// both sides of the boundary compare against one deterministic value.
var FixtureTime = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
// FixtureUUID is the fixed UUID used by the shared DbValue fixtures.
var FixtureUUID = uuid.MustParse("11111111-2222-3333-4444-555555555555")
// DbValueFixture is one entry in the canonical DbValue↔Go mapping table.
type DbValueFixture struct {
// Name identifies the DbValue variant.
Name string
// Value is the wire value the host would send back in a DbRowsResponse cell
// (and the guest would send as an argument for the non-NULL scalar cases).
Value *abiv1.DbValue
// NewDest returns a fresh pointer to the canonical Go scan destination type
// that plugin sqlc code binds for this variant.
NewDest func() any
// Want is the expected dereferenced value after scanning Value into
// NewDest(). Compared with reflect.DeepEqual (times via .Equal).
Want any
// DriverValue is the expected database/sql driver.Value for this variant,
// documenting the "bnwasm" driver's mapping (uuid/numeric→string,
// text[]→Postgres array literal).
DriverValue any
}
// DbValueFixtures is the authoritative DbValue↔Go mapping table. It is the
// shared contract WO-WZ-007 (the CMS host executor) MUST mirror: every variant
// the guest driver produces on scan, the host must be able to build on read,
// and vice-versa. Exported (non-test) precisely so the host's tests in the CMS
// module can import and assert against the same table rather than duplicating a
// drift-prone copy.
//
// Every oneof arm of abiv1.DbValue.Kind appears at least once; several arms
// carry extra entries that exercise encoding edge cases (the zero timestamp,
// negative/very-large numerics, an empty text[], and text[] elements that force
// encodePgTextArray's quoting/escaping paths) so the host mirror must reproduce
// them too.
//
// Contract limit — NULL array elements: abiv1.TextArray is a repeated string,
// which has no per-element NULL. A Postgres text[] value like '{a,NULL,b}'
// therefore CANNOT round-trip through this boundary: a NULL element collapses to
// the empty string "". The whole array can still be SQL NULL (a nil []string /
// DbValue_Null), but an individual NULL *inside* the array is unrepresentable.
// The WO-WZ-007 host executor MUST honor this same limit (encode a NULL element
// as "" or reject it) — it must not invent a sentinel.
var DbValueFixtures = []DbValueFixture{
{
Name: "null",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Null{Null: true}},
NewDest: func() any { return new(*string) }, // **string: NULL → (*string)(nil)
Want: (*string)(nil),
DriverValue: nil,
},
{
Name: "bool",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: true}},
NewDest: func() any { return new(bool) },
Want: true,
DriverValue: true,
},
{
Name: "int64",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 42}},
NewDest: func() any { return new(int64) },
Want: int64(42),
DriverValue: int64(42),
},
{
Name: "int32", // narrowing coercion sqlc emits for int4 columns
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 7}},
NewDest: func() any { return new(int32) },
Want: int32(7),
DriverValue: int64(7),
},
{
Name: "float64",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_Float64Value{Float64Value: 3.5}},
NewDest: func() any { return new(float64) },
Want: 3.5,
DriverValue: 3.5,
},
{
Name: "string",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: "hello"}},
NewDest: func() any { return new(string) },
Want: "hello",
DriverValue: "hello",
},
{
Name: "bytes",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_BytesValue{BytesValue: []byte{0x01, 0x02, 0x03}}},
NewDest: func() any { return new([]byte) },
Want: []byte{0x01, 0x02, 0x03},
DriverValue: []byte{0x01, 0x02, 0x03},
},
{
Name: "timestamp",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(FixtureTime)}},
NewDest: func() any { return new(time.Time) },
Want: FixtureTime,
DriverValue: FixtureTime,
},
{
Name: "uuid",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: FixtureUUID.String()}},
NewDest: func() any { return new(uuid.UUID) },
Want: FixtureUUID,
DriverValue: FixtureUUID.String(),
},
{
Name: "jsonb",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_JsonbValue{JsonbValue: []byte(`{"a":1}`)}},
NewDest: func() any { return new(json.RawMessage) },
Want: json.RawMessage(`{"a":1}`),
DriverValue: []byte(`{"a":1}`),
},
{
Name: "numeric",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "123.45"}},
NewDest: func() any { return new(string) },
Want: "123.45",
DriverValue: "123.45",
},
{
Name: "text_array",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{"a", "b"}}}},
NewDest: func() any { return new([]string) },
Want: []string{"a", "b"},
DriverValue: `{"a","b"}`,
},
// --- encoding edge cases (extra entries beyond one-per-variant) ---
{
Name: "timestamp_zero", // the Go zero time.Time (year 1) must survive the timestamppb round-trip
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(time.Time{})}},
NewDest: func() any { return new(time.Time) },
Want: time.Time{},
DriverValue: time.Time{},
},
{
Name: "numeric_negative",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "-98765.4321"}},
NewDest: func() any { return new(string) },
Want: "-98765.4321",
DriverValue: "-98765.4321",
},
{
Name: "numeric_large", // far beyond int64/float64 range: numeric stays a lossless decimal string
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "123456789012345678901234567890.123456789"}},
NewDest: func() any { return new(string) },
Want: "123456789012345678901234567890.123456789",
DriverValue: "123456789012345678901234567890.123456789",
},
{
Name: "text_array_empty", // empty-but-non-nil text[] → "{}" (distinct from a NULL array)
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{}}}},
NewDest: func() any { return new([]string) },
Want: []string(nil),
DriverValue: "{}",
},
{
// Elements that force encodePgTextArray's quoting/escaping: a comma
// (needs quoting), a double-quote and a backslash (need escaping), and an
// empty-string element (renders as the empty quoted "").
Name: "text_array_quoting",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{"a,b", `x"y`, `p\q`, ""}}}},
NewDest: func() any { return new([]string) },
Want: []string{"a,b", `x"y`, `p\q`, ""},
DriverValue: `{"a,b","x\"y","p\\q",""}`,
},
}

View File

@ -0,0 +1,75 @@
package bnwasm
import (
"encoding/json"
"reflect"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// TestToDbValueTextArrayNilVsEmpty pins the guest-side arg encoding for []string:
// a nil slice marshals to SQL NULL (matching []byte/json.RawMessage), while an
// empty-but-non-nil slice stays a non-NULL empty text[]. WO-WZ-007's host must
// mirror this nil→NULL convention.
func TestToDbValueTextArrayNilVsEmpty(t *testing.T) {
t.Run("nil", func(t *testing.T) {
dv, err := toDbValue([]string(nil))
if err != nil {
t.Fatal(err)
}
if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok {
t.Fatalf("nil []string: want DbValue_Null, got %T", dv.GetKind())
}
})
t.Run("empty", func(t *testing.T) {
dv, err := toDbValue([]string{})
if err != nil {
t.Fatal(err)
}
ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue)
if !ok {
t.Fatalf("empty []string: want DbValue_TextArrayValue, got %T", dv.GetKind())
}
if got := ta.TextArrayValue.GetValues(); len(got) != 0 {
t.Fatalf("empty []string: want zero-length text[], got %#v", got)
}
})
t.Run("values", func(t *testing.T) {
dv, err := toDbValue([]string{"a", "b"})
if err != nil {
t.Fatal(err)
}
ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue)
if !ok {
t.Fatalf("want DbValue_TextArrayValue, got %T", dv.GetKind())
}
if got := ta.TextArrayValue.GetValues(); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("want [a b], got %#v", got)
}
})
}
// TestToDbValueNilConvention locks the nil→NULL convention across the reference
// types so []string stays consistent with []byte and json.RawMessage.
func TestToDbValueNilConvention(t *testing.T) {
cases := []struct {
name string
in any
}{
{"bytes", []byte(nil)},
{"json", json.RawMessage(nil)},
{"text_array", []string(nil)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dv, err := toDbValue(c.in)
if err != nil {
t.Fatal(err)
}
if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok {
t.Fatalf("nil %s: want DbValue_Null, got %T", c.name, dv.GetKind())
}
})
}
}

View File

@ -0,0 +1,201 @@
package bnwasm
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"sync"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// DriverName is the database/sql driver name registered by this package.
const DriverName = "bnwasm"
func init() { sql.Register(DriverName, Driver{}) }
// defaultTransport is the process-wide transport the "bnwasm" database/sql
// driver uses (the guest has exactly one host). The wasip1 shim sets it via
// SetDefaultTransport; native builds leave it nil so opened connections fail
// cleanly with errNoHost. Guarded because sql.DB may dial from any goroutine.
var (
defaultMu sync.RWMutex
defaultTransport Transport
)
// SetDefaultTransport binds the transport used by database/sql connections
// opened via sql.Open("bnwasm", ...). Called once from the wasm guest shim.
func SetDefaultTransport(t Transport) {
defaultMu.Lock()
defaultTransport = t
defaultMu.Unlock()
}
func currentTransport() Transport {
defaultMu.RLock()
defer defaultMu.RUnlock()
return defaultTransport
}
// Driver is the database/sql/driver.Driver for the wasm DB ABI.
type Driver struct{}
var (
_ driver.Driver = Driver{}
_ driver.DriverContext = Driver{}
)
// Open ignores its DSN — the transport is process-global (one host per guest).
func (d Driver) Open(name string) (driver.Conn, error) {
return &conn{t: currentTransport()}, nil
}
// OpenConnector lets sql.OpenDB bypass DSN parsing entirely.
func (d Driver) OpenConnector(name string) (driver.Connector, error) {
return connector{}, nil
}
// NewConnector returns a driver.Connector bound to an explicit transport, for
// callers that construct a *sql.DB via sql.OpenDB without touching the global.
func NewConnector(t Transport) driver.Connector { return connector{t: t, explicit: true} }
type connector struct {
t Transport
explicit bool
}
func (c connector) Connect(context.Context) (driver.Conn, error) {
t := c.t
if !c.explicit {
t = currentTransport()
}
return &conn{t: t}, nil
}
func (c connector) Driver() driver.Driver { return Driver{} }
// conn is one logical connection: it holds no real connection state guest-side,
// only the transport and the current transaction handle (0 = autocommit).
type conn struct {
t Transport
txHandle uint64
inTx bool
}
var (
_ driver.Conn = (*conn)(nil)
_ driver.QueryerContext = (*conn)(nil)
_ driver.ExecerContext = (*conn)(nil)
_ driver.ConnBeginTx = (*conn)(nil)
)
func (c *conn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("bnwasm: prepared statements are not supported; use QueryContext/ExecContext")
}
func (c *conn) Close() error { return nil }
func (c *conn) Begin() (driver.Tx, error) { return c.BeginTx(context.Background(), driver.TxOptions{}) }
func (c *conn) BeginTx(ctx context.Context, _ driver.TxOptions) (driver.Tx, error) {
if c.inTx {
return nil, errNestedTx
}
handle, err := c.t.txBegin(ctx)
if err != nil {
return nil, err
}
c.txHandle = handle
c.inTx = true
return &sqlTx{c: c}, nil
}
func (c *conn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
posArgs, err := namedToPositional(args)
if err != nil {
return nil, err
}
tag, err := c.t.exec(ctx, query, c.txHandle, posArgs)
if err != nil {
return nil, err
}
return sqlResult{rowsAffected: tag.RowsAffected()}, nil
}
func (c *conn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
posArgs, err := namedToPositional(args)
if err != nil {
return nil, err
}
resp, err := c.t.query(ctx, query, c.txHandle, posArgs)
if err != nil {
return nil, err
}
return newDriverRows(resp), nil
}
// sqlTx is the database/sql transaction handle; it clears the conn's handle on
// completion so subsequent statements run in autocommit again.
type sqlTx struct{ c *conn }
func (t sqlTx) Commit() error {
err := t.c.t.txCommit(context.Background(), t.c.txHandle)
t.c.txHandle, t.c.inTx = 0, false
return err
}
func (t sqlTx) Rollback() error {
err := t.c.t.txRollback(context.Background(), t.c.txHandle)
t.c.txHandle, t.c.inTx = 0, false
return err
}
type sqlResult struct{ rowsAffected int64 }
func (r sqlResult) LastInsertId() (int64, error) {
return 0, fmt.Errorf("bnwasm: LastInsertId is not supported (Postgres uses RETURNING)")
}
func (r sqlResult) RowsAffected() (int64, error) { return r.rowsAffected, nil }
// driverRows adapts a buffered DbRowsResponse to database/sql/driver.Rows.
type driverRows struct {
resp *abiv1.DbRowsResponse
idx int
}
func newDriverRows(resp *abiv1.DbRowsResponse) *driverRows { return &driverRows{resp: resp, idx: 0} }
func (r *driverRows) Columns() []string { return r.resp.GetColumns() }
func (r *driverRows) Close() error { return nil }
func (r *driverRows) Next(dest []driver.Value) error {
rows := r.resp.GetRows()
if r.idx >= len(rows) {
return io.EOF
}
cells := rows[r.idx].GetValues()
if len(dest) != len(cells) {
return fmt.Errorf("bnwasm: expected %d columns, got %d destinations", len(cells), len(dest))
}
for i, cell := range cells {
dest[i] = driverValue(cell)
}
r.idx++
return nil
}
// namedToPositional flattens database/sql NamedValues to positional args,
// rejecting any that carry a name (sqlc emits positional $N parameters only).
func namedToPositional(args []driver.NamedValue) ([]any, error) {
out := make([]any, len(args))
for i, a := range args {
if a.Name != "" {
return nil, fmt.Errorf("bnwasm: named argument %q is not supported; use positional parameters", a.Name)
}
out[i] = a.Value
}
return out, nil
}

View File

@ -0,0 +1,304 @@
package bnwasm
import (
"context"
"database/sql"
"errors"
"reflect"
"testing"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"google.golang.org/protobuf/proto"
)
// recordedCall captures one db.* host call for ordered-sequence assertions.
type recordedCall struct {
method string
sql string
args []*abiv1.DbValue
txHandle uint64
}
// fakeHost is a scripted db.* transport: it records every call and answers from
// programmable fields, so the guest driver is exercised end-to-end with no real
// Postgres and the exact host-call sequence can be asserted.
type fakeHost struct {
calls []recordedCall
queryResp *abiv1.DbRowsResponse // returned for db.query
execRows int64 // rows_affected for db.exec
txHandle uint64 // handle handed out on db.tx_begin
dbError *abiv1.DbError // if set, attached to the next query/exec response
failWith error // if set, the transport returns this transport-level error
}
func (f *fakeHost) call(method string, req, resp proto.Message) error {
rc := recordedCall{method: method}
switch r := req.(type) {
case *abiv1.DbQueryRequest:
rc.sql, rc.args, rc.txHandle = r.GetSql(), r.GetArgs(), r.GetTxHandle()
case *abiv1.DbExecRequest:
rc.sql, rc.args, rc.txHandle = r.GetSql(), r.GetArgs(), r.GetTxHandle()
case *abiv1.DbTxCommitRequest:
rc.txHandle = r.GetTxHandle()
case *abiv1.DbTxRollbackRequest:
rc.txHandle = r.GetTxHandle()
}
f.calls = append(f.calls, rc)
if f.failWith != nil {
return f.failWith
}
switch out := resp.(type) {
case *abiv1.DbRowsResponse:
if f.queryResp != nil {
proto.Merge(out, f.queryResp)
}
out.Error = f.dbError
case *abiv1.DbExecResponse:
out.RowsAffected = f.execRows
out.Error = f.dbError
case *abiv1.DbTxBeginResponse:
out.TxHandle = f.txHandle
out.Error = f.dbError
case *abiv1.DbTxCommitResponse:
out.Error = f.dbError
case *abiv1.DbTxRollbackResponse:
out.Error = f.dbError
}
return nil
}
func (f *fakeHost) methods() []string {
ms := make([]string, len(f.calls))
for i, c := range f.calls {
ms[i] = c.method
}
return ms
}
func rowsWith(columns []string, cells ...*abiv1.DbValue) *abiv1.DbRowsResponse {
return &abiv1.DbRowsResponse{Columns: columns, Rows: []*abiv1.DbRow{{Values: cells}}}
}
// deref returns the value a NewDest()-style pointer points at.
func deref(ptr any) any { return reflect.ValueOf(ptr).Elem().Interface() }
func equalScan(want, got any) bool {
if w, ok := want.(time.Time); ok {
g, ok := got.(time.Time)
return ok && w.Equal(g)
}
return reflect.DeepEqual(want, got)
}
// TestDbValueScanRoundTrip proves every DbValue variant round-trips through a
// SELECT and scans into its canonical Go type — the core contract WO-WZ-007
// mirrors. Driven by the shared DbValueFixtures table.
func TestDbValueScanRoundTrip(t *testing.T) {
for _, fx := range DbValueFixtures {
t.Run(fx.Name, func(t *testing.T) {
host := &fakeHost{queryResp: rowsWith([]string{fx.Name}, fx.Value)}
pool := NewPool(host.call)
dest := fx.NewDest()
if err := pool.QueryRow(context.Background(), "SELECT x").Scan(dest); err != nil {
t.Fatalf("scan %s: %v", fx.Name, err)
}
if got := deref(dest); !equalScan(fx.Want, got) {
t.Fatalf("scan %s: want %#v, got %#v", fx.Name, fx.Want, got)
}
})
}
}
// TestDbValueDriverValue proves the database/sql "bnwasm" driver maps every
// DbValue variant to the documented driver.Value.
func TestDbValueDriverValue(t *testing.T) {
for _, fx := range DbValueFixtures {
t.Run(fx.Name, func(t *testing.T) {
if got := driverValue(fx.Value); !equalScan(fx.DriverValue, got) {
t.Fatalf("driverValue %s: want %#v, got %#v", fx.Name, fx.DriverValue, got)
}
})
}
}
func TestExecRowsAffected(t *testing.T) {
host := &fakeHost{execRows: 5}
pool := NewPool(host.call)
tag, err := pool.Exec(context.Background(), "UPDATE t SET x = 1")
if err != nil {
t.Fatal(err)
}
if tag.RowsAffected() != 5 {
t.Fatalf("rows affected: want 5, got %d", tag.RowsAffected())
}
if host.calls[0].txHandle != 0 {
t.Fatalf("autocommit exec should carry tx_handle 0, got %d", host.calls[0].txHandle)
}
}
// TestTxCommitSequence asserts the ordered host calls for a begin/exec/commit
// transaction, and that every statement inside carries the tx handle.
func TestTxCommitSequence(t *testing.T) {
host := &fakeHost{txHandle: 77, execRows: 1}
pool := NewPool(host.call)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, "INSERT INTO t VALUES ($1)", 1); err != nil {
t.Fatal(err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
wantMethods := []string{methodTxBegin, methodExec, methodTxCommit}
if got := host.methods(); !reflect.DeepEqual(got, wantMethods) {
t.Fatalf("method sequence: want %v, got %v", wantMethods, got)
}
if host.calls[1].txHandle != 77 {
t.Fatalf("in-tx exec should carry handle 77, got %d", host.calls[1].txHandle)
}
if host.calls[2].txHandle != 77 {
t.Fatalf("commit should carry handle 77, got %d", host.calls[2].txHandle)
}
}
// TestTxRollbackThenAutocommit asserts a rollback sequence, and that a query
// issued afterward (on the pool) carries NO tx handle.
func TestTxRollbackThenAutocommit(t *testing.T) {
host := &fakeHost{txHandle: 9, execRows: 1}
pool := NewPool(host.call)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if err := tx.Rollback(ctx); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "SELECT 1"); err != nil {
t.Fatal(err)
}
wantMethods := []string{methodTxBegin, methodTxRollback, methodExec}
if got := host.methods(); !reflect.DeepEqual(got, wantMethods) {
t.Fatalf("method sequence: want %v, got %v", wantMethods, got)
}
if h := host.calls[1].txHandle; h != 9 {
t.Fatalf("rollback should carry handle 9, got %d", h)
}
if h := host.calls[2].txHandle; h != 0 {
t.Fatalf("post-rollback exec must carry tx_handle 0, got %d", h)
}
}
func TestTxUseAfterCloseRejected(t *testing.T) {
host := &fakeHost{txHandle: 3}
tx, err := NewPool(host.call).Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(context.Background(), "SELECT 1"); !errors.Is(err, pgx.ErrTxClosed) {
t.Fatalf("exec after commit: want ErrTxClosed, got %v", err)
}
if err := tx.Rollback(context.Background()); !errors.Is(err, pgx.ErrTxClosed) {
t.Fatalf("rollback after commit: want ErrTxClosed, got %v", err)
}
}
func TestNestedTxRejected(t *testing.T) {
host := &fakeHost{txHandle: 1}
tx, err := NewPool(host.call).Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := tx.Begin(context.Background()); !errors.Is(err, errNestedTx) {
t.Fatalf("nested Begin: want errNestedTx, got %v", err)
}
}
func TestNamedArgsRejected(t *testing.T) {
host := &fakeHost{}
pool := NewPool(host.call)
_, err := pool.Query(context.Background(), "SELECT $1", pgx.NamedArgs{"a": 1})
if err == nil {
t.Fatal("expected named-args rejection, got nil")
}
}
// TestDbErrorSurfacesAsPgError proves a DbError carrying a SQLSTATE reaches the
// plugin as a *pgconn.PgError, so errors.As-based constraint checks keep working.
func TestDbErrorSurfacesAsPgError(t *testing.T) {
host := &fakeHost{dbError: &abiv1.DbError{Code: "23505", Message: "duplicate key"}}
_, err := NewPool(host.call).Exec(context.Background(), "INSERT INTO t VALUES (1)")
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
t.Fatalf("want *pgconn.PgError, got %T (%v)", err, err)
}
if pgErr.Code != "23505" {
t.Fatalf("want SQLSTATE 23505, got %q", pgErr.Code)
}
}
func TestNoHostFailsCleanly(t *testing.T) {
pool := NewPool(nil)
if _, err := pool.Exec(context.Background(), "SELECT 1"); !errors.Is(err, errNoHost) {
t.Fatalf("want errNoHost, got %v", err)
}
}
func TestQueryRowNoRows(t *testing.T) {
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{Columns: []string{"x"}}}
var x int
err := NewPool(host.call).QueryRow(context.Background(), "SELECT x").Scan(&x)
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("want ErrNoRows, got %v", err)
}
}
// TestDatabaseSQLDriverRegistered proves the "bnwasm" driver is registered and
// drives a SELECT end-to-end through database/sql against the fake host.
func TestDatabaseSQLDriverRegistered(t *testing.T) {
host := &fakeHost{queryResp: rowsWith(
[]string{"id", "name"},
&abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: 1}},
&abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: "alice"}},
)}
db := sql.OpenDB(NewConnector(host.call))
defer db.Close()
var id int
var name string
if err := db.QueryRowContext(context.Background(), "SELECT id, name FROM t").Scan(&id, &name); err != nil {
t.Fatal(err)
}
if id != 1 || name != "alice" {
t.Fatalf("got id=%d name=%q", id, name)
}
if !slicesContains(sql.Drivers(), DriverName) {
t.Fatalf("driver %q not registered; have %v", DriverName, sql.Drivers())
}
}
func slicesContains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}

View File

@ -0,0 +1,60 @@
package bnwasm
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Pool implements plugin.Pool (the pgx-flavored interface plugins bind their
// sqlc DBTX to) over the db.* host calls. It is also itself a valid DBTX: its
// Exec/Query/QueryRow run in autocommit (tx_handle 0), so a plugin can pass the
// Pool directly to sqlc's New(db) for non-transactional queries, exactly as it
// passes a *pgxpool.Pool today.
type Pool struct {
t Transport
}
// NewPool returns a Pool bound to transport t. A nil t (native build / DESCRIBE
// probe) yields a Pool whose every operation fails cleanly with errNoHost.
func NewPool(t Transport) *Pool { return &Pool{t: t} }
// Begin opens a host-side transaction and returns a pgx.Tx bound to its handle.
func (p *Pool) Begin(ctx context.Context) (pgx.Tx, error) {
handle, err := p.t.txBegin(ctx)
if err != nil {
return nil, err
}
return &Tx{t: p.t, handle: handle}, nil
}
// Exec runs a statement in autocommit and returns its command tag.
func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
return p.t.exec(ctx, sql, 0, args)
}
// Query runs a rows-returning statement in autocommit.
func (p *Pool) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
resp, err := p.t.query(ctx, sql, 0, args)
if err != nil {
return errRows(err), err
}
return newPgxRows(resp), nil
}
// QueryRow runs a rows-returning statement in autocommit and returns the first
// row; any error is deferred to Row.Scan, matching pgx.
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
resp, err := p.t.query(ctx, sql, 0, args)
if err != nil {
return &pgxRow{err: err}
}
return &pgxRow{rows: newPgxRows(resp)}
}
// errRows is a closed, empty pgx.Rows carrying err, so a Query caller that
// ignores the returned error and iterates still terminates and reports it.
func errRows(err error) *pgxRows {
return &pgxRows{idx: -1, err: err, closed: true}
}

View File

@ -0,0 +1,120 @@
package bnwasm
import (
"errors"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// pgxRows adapts a buffered DbRowsResponse to the pgx.Rows interface. The full
// result set is already materialized (the host returns it in one message), so
// iteration is a slice cursor. Only the methods sqlc-generated code exercises
// carry real behavior; the rest satisfy the interface.
type pgxRows struct {
columns []string
rows []*abiv1.DbRow
idx int // index of the row Next() last advanced to; -1 before first Next
err error
closed bool
}
func newPgxRows(resp *abiv1.DbRowsResponse) *pgxRows {
return &pgxRows{columns: resp.GetColumns(), rows: resp.GetRows(), idx: -1}
}
func (r *pgxRows) Close() { r.closed = true }
func (r *pgxRows) Err() error { return r.err }
func (r *pgxRows) CommandTag() pgconn.CommandTag {
return pgconn.NewCommandTag(fmt.Sprintf("SELECT %d", len(r.rows)))
}
func (r *pgxRows) FieldDescriptions() []pgconn.FieldDescription {
fds := make([]pgconn.FieldDescription, len(r.columns))
for i, c := range r.columns {
fds[i] = pgconn.FieldDescription{Name: c}
}
return fds
}
func (r *pgxRows) Next() bool {
if r.err != nil || r.closed {
return false
}
if r.idx+1 >= len(r.rows) {
r.Close() // pgx auto-closes when iteration is exhausted
return false
}
r.idx++
return true
}
func (r *pgxRows) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
if r.idx < 0 || r.idx >= len(r.rows) {
return errors.New("bnwasm: Scan called without a current row (call Next first)")
}
if err := scanRow(r.rows[r.idx], dest); err != nil {
r.err = err
r.Close()
return err
}
return nil
}
func (r *pgxRows) Values() ([]any, error) {
if r.idx < 0 || r.idx >= len(r.rows) {
return nil, errors.New("bnwasm: Values called without a current row")
}
cells := r.rows[r.idx].GetValues()
out := make([]any, len(cells))
for i, c := range cells {
v, _ := naturalValue(c)
out[i] = v
}
return out, nil
}
func (r *pgxRows) RawValues() [][]byte { return nil }
func (r *pgxRows) Conn() *pgx.Conn { return nil }
// pgxRow is the single-row QueryRow result. Like pgx, it defers all errors
// (including the query error) to Scan, and reports pgx.ErrNoRows when empty.
type pgxRow struct {
rows *pgxRows
err error
}
func (r *pgxRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
defer r.rows.Close()
if !r.rows.Next() {
if r.rows.Err() != nil {
return r.rows.Err()
}
return pgx.ErrNoRows
}
return r.rows.Scan(dest...)
}
func scanRow(row *abiv1.DbRow, dest []any) error {
cells := row.GetValues()
if len(dest) != len(cells) {
return fmt.Errorf("bnwasm: scan expected %d destinations, got %d columns", len(dest), len(cells))
}
for i, d := range dest {
if err := scanValue(cells[i], d); err != nil {
return fmt.Errorf("bnwasm: scan column %d: %w", i, err)
}
}
return nil
}

View File

@ -0,0 +1,207 @@
package bnwasm
import (
"context"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
)
// --- Vendored sqlc output ---------------------------------------------------
//
// The block below is byte-for-byte in the shape `sqlc generate` emits for the
// pgx/v5 sql_package with the same overrides symposium uses (uuid→uuid.UUID,
// jsonb→json.RawMessage, emit_pointers_for_null_types). It is checked in so a
// compile of THIS test is proof that generated plugin code binds to the bnwasm
// Pool/Tx (as its DBTX) and pgxRows/pgxRow (via Scan) with no edits — the same
// DBTX interface, the same pgconn.CommandTag / pgx.Rows / pgx.Row signatures.
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries { return &Queries{db: db} }
type Queries struct{ db DBTX }
func (q *Queries) WithTx(tx pgx.Tx) *Queries { return &Queries{db: tx} }
type Widget struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Count int32 `json:"count"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
const createWidget = `-- name: CreateWidget :one
INSERT INTO widgets (name, notes) VALUES ($1, $2) RETURNING id
`
type CreateWidgetParams struct {
Name string `json:"name"`
Notes *string `json:"notes"`
}
func (q *Queries) CreateWidget(ctx context.Context, arg CreateWidgetParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, createWidget, arg.Name, arg.Notes)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getWidget = `-- name: GetWidget :one
SELECT id, name, notes, count, created_at FROM widgets WHERE id = $1
`
func (q *Queries) GetWidget(ctx context.Context, id uuid.UUID) (Widget, error) {
row := q.db.QueryRow(ctx, getWidget, id)
var i Widget
err := row.Scan(&i.ID, &i.Name, &i.Notes, &i.Count, &i.CreatedAt)
return i, err
}
const listWidgets = `-- name: ListWidgets :many
SELECT id, name, notes, count, created_at FROM widgets ORDER BY name
`
func (q *Queries) ListWidgets(ctx context.Context) ([]Widget, error) {
rows, err := q.db.Query(ctx, listWidgets)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Widget
for rows.Next() {
var i Widget
if err := rows.Scan(&i.ID, &i.Name, &i.Notes, &i.Count, &i.CreatedAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteWidget = `-- name: DeleteWidget :exec
DELETE FROM widgets WHERE id = $1
`
func (q *Queries) DeleteWidget(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, deleteWidget, id)
return err
}
// --- Compile-time proof the bnwasm surfaces satisfy the generated interfaces --
var (
_ DBTX = (*Pool)(nil)
_ DBTX = (*Tx)(nil)
_ pgx.Tx = (*Tx)(nil)
)
// --- End-to-end runs against the fake host ----------------------------------
func widgetRow(id uuid.UUID, name string, count int32) *abiv1.DbRow {
return &abiv1.DbRow{Values: []*abiv1.DbValue{
{Kind: &abiv1.DbValue_UuidValue{UuidValue: id.String()}},
{Kind: &abiv1.DbValue_StringValue{StringValue: name}},
{Kind: &abiv1.DbValue_Null{Null: true}}, // notes → NULL → (*string)(nil)
{Kind: &abiv1.DbValue_Int64Value{Int64Value: int64(count)}},
{Kind: &abiv1.DbValue_Null{Null: true}}, // created_at → NULL Timestamptz
}}
}
func TestSqlcGetWidget(t *testing.T) {
id := uuid.New()
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id", "name", "notes", "count", "created_at"},
Rows: []*abiv1.DbRow{widgetRow(id, "gadget", 3)},
}}
q := New(NewPool(host.call))
w, err := q.GetWidget(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if w.ID != id || w.Name != "gadget" || w.Count != 3 {
t.Fatalf("unexpected widget: %+v", w)
}
if w.Notes != nil {
t.Fatalf("notes should be nil for NULL column, got %v", *w.Notes)
}
if w.CreatedAt.Valid {
t.Fatalf("created_at should be invalid for NULL column")
}
}
func TestSqlcListWidgets(t *testing.T) {
host := &fakeHost{queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id", "name", "notes", "count", "created_at"},
Rows: []*abiv1.DbRow{
widgetRow(uuid.New(), "alpha", 1),
widgetRow(uuid.New(), "beta", 2),
},
}}
q := New(NewPool(host.call))
items, err := q.ListWidgets(context.Background())
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0].Name != "alpha" || items[1].Name != "beta" {
t.Fatalf("unexpected list: %+v", items)
}
}
// TestSqlcWithTxCreate proves the generated WithTx path binds to the bnwasm Tx
// and that CreateWidget's RETURNING id scans, all inside one transaction with
// the right ordered host calls.
func TestSqlcWithTxCreate(t *testing.T) {
newID := uuid.New()
host := &fakeHost{
txHandle: 42,
queryResp: &abiv1.DbRowsResponse{
Columns: []string{"id"},
Rows: []*abiv1.DbRow{{Values: []*abiv1.DbValue{{Kind: &abiv1.DbValue_UuidValue{UuidValue: newID.String()}}}}},
},
}
pool := NewPool(host.call)
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
notes := "hi"
gotID, err := New(pool).WithTx(tx).CreateWidget(ctx, CreateWidgetParams{Name: "w", Notes: &notes})
if err != nil {
t.Fatal(err)
}
if gotID != newID {
t.Fatalf("returning id: want %s got %s", newID, gotID)
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
// begin → query(RETURNING) → commit; the query carried the tx handle.
if got := host.methods(); len(got) != 3 || got[0] != methodTxBegin || got[1] != methodQuery || got[2] != methodTxCommit {
t.Fatalf("method sequence: %v", got)
}
if host.calls[1].txHandle != 42 {
t.Fatalf("in-tx query should carry handle 42, got %d", host.calls[1].txHandle)
}
// The NULL-able *string arg marshaled as a real string arg.
if len(host.calls[1].args) != 2 {
t.Fatalf("want 2 args, got %d", len(host.calls[1].args))
}
}

View File

@ -0,0 +1,164 @@
// Package bnwasm is the guest-side database access layer for wazero-loaded
// BlockNinja plugins. It presents two surfaces over the SAME db.* host calls
// (abiv1 db.proto), so plugin code that talks to Postgres keeps compiling and
// running unchanged inside the wasm sandbox:
//
// - A database/sql/driver registered as "bnwasm" (driver.go), for plugins or
// sqlc configs that use the standard library.
// - A plugin.Pool implementation (pool.go) that hands out a pgx.Tx-shaped
// value (tx.go), so the pgx-flavored sqlc DBTX interface every current
// plugin generates against (sql_package: "pgx/v5") is satisfied with no
// source edits. This is the PRIMARY path: symposium/messenger sqlc output
// uses pgconn.CommandTag / pgx.Rows / pgx.Row, which database/sql cannot
// produce, so the pgx surface is what their generated db packages bind to.
//
// # The transport seam
//
// Every DB operation is "marshal a db.<op> request, invoke it, unmarshal the
// reply". That transport is a Transport func injected at construction, mirroring
// caps.CallFunc exactly, so the marshaling/scanning logic here stays natively
// testable with a fake host (driver_test.go, sqlcgen_test.go) while the wasm
// guest shim binds the real host_call-backed transport (wired from package
// wasmguest, which imports this package — never the reverse). A nil Transport
// (native builds, DESCRIBE probes) fails every call cleanly with errNoHost
// instead of nil-panicking, matching package caps.
package bnwasm
import (
"context"
"errors"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/jackc/pgx/v5/pgconn"
"google.golang.org/protobuf/proto"
)
// Transport is the guest→host DB transport: marshal req, invoke the host with
// the db.* method, and unmarshal the reply into resp. It is structurally
// identical to caps.CallFunc so the wasip1 shim can bind one func to both.
type Transport func(method string, req, resp proto.Message) error
// db.* method names (see core/docs/wasm-abi.md §"Capability calls").
const (
methodQuery = "db.query"
methodExec = "db.exec"
methodTxBegin = "db.tx_begin"
methodTxCommit = "db.tx_commit"
methodTxRollback = "db.tx_rollback"
)
// errNoHost is returned by every operation when no transport is bound (native
// build / DESCRIBE probe). It is distinct so tests can assert on it.
var errNoHost = errors.New("bnwasm: no host transport bound (native build)")
// dbErr maps a DbError from a response into a *pgconn.PgError, the same error
// type pgx surfaces, so plugin code that does errors.As(err, &pgErr) to inspect
// a SQLSTATE (e.g. "23505" unique_violation) keeps working across the sandbox.
func dbErr(e *abiv1.DbError) error {
if e == nil {
return nil
}
return &pgconn.PgError{Code: e.GetCode(), Message: e.GetMessage()}
}
func (t Transport) query(ctx context.Context, sql string, txHandle uint64, args []any) (*abiv1.DbRowsResponse, error) {
if t == nil {
return nil, errNoHost
}
if err := ctxErr(ctx); err != nil {
return nil, err
}
vals, err := toDbValues(args)
if err != nil {
return nil, err
}
resp := &abiv1.DbRowsResponse{}
if err := t(methodQuery, &abiv1.DbQueryRequest{Sql: sql, Args: vals, TxHandle: txHandle}, resp); err != nil {
return nil, err
}
if err := dbErr(resp.GetError()); err != nil {
return nil, err
}
return resp, nil
}
func (t Transport) exec(ctx context.Context, sql string, txHandle uint64, args []any) (pgconn.CommandTag, error) {
if t == nil {
return pgconn.CommandTag{}, errNoHost
}
if err := ctxErr(ctx); err != nil {
return pgconn.CommandTag{}, err
}
vals, err := toDbValues(args)
if err != nil {
return pgconn.CommandTag{}, err
}
resp := &abiv1.DbExecResponse{}
if err := t(methodExec, &abiv1.DbExecRequest{Sql: sql, Args: vals, TxHandle: txHandle}, resp); err != nil {
return pgconn.CommandTag{}, err
}
if err := dbErr(resp.GetError()); err != nil {
return pgconn.CommandTag{}, err
}
// Encode rows-affected in a CommandTag whose trailing integer pgconn parses
// back out via RowsAffected() — the only field sqlc-generated code reads.
return pgconn.NewCommandTag(fmt.Sprintf("EXEC %d", resp.GetRowsAffected())), nil
}
func (t Transport) txBegin(ctx context.Context) (uint64, error) {
if t == nil {
return 0, errNoHost
}
if err := ctxErr(ctx); err != nil {
return 0, err
}
resp := &abiv1.DbTxBeginResponse{}
if err := t(methodTxBegin, &abiv1.DbTxBeginRequest{}, resp); err != nil {
return 0, err
}
if err := dbErr(resp.GetError()); err != nil {
return 0, err
}
if resp.GetTxHandle() == 0 {
return 0, errors.New("bnwasm: host returned tx_handle 0 (never valid)")
}
return resp.GetTxHandle(), nil
}
func (t Transport) txCommit(ctx context.Context, handle uint64) error {
if t == nil {
return errNoHost
}
if err := ctxErr(ctx); err != nil {
return err
}
resp := &abiv1.DbTxCommitResponse{}
if err := t(methodTxCommit, &abiv1.DbTxCommitRequest{TxHandle: handle}, resp); err != nil {
return err
}
return dbErr(resp.GetError())
}
func (t Transport) txRollback(ctx context.Context, handle uint64) error {
if t == nil {
return errNoHost
}
if err := ctxErr(ctx); err != nil {
return err
}
resp := &abiv1.DbTxRollbackResponse{}
if err := t(methodTxRollback, &abiv1.DbTxRollbackRequest{TxHandle: handle}, resp); err != nil {
return err
}
return dbErr(resp.GetError())
}
// ctxErr short-circuits an already-cancelled/expired context before crossing
// the boundary, matching the caps stubs; the host enforces the invoke deadline.
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}

View File

@ -0,0 +1,102 @@
package bnwasm
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// errNestedTx is returned by Tx.Begin. pgx models a nested Begin as a SAVEPOINT;
// the db.* ABI has no savepoint verb (v1 scope), and a grep of the plugin fleet
// (symposium, messenger) found no nested-transaction/savepoint use, so this is
// rejected explicitly rather than silently degrading correctness.
var errNestedTx = errors.New("bnwasm: nested transactions (savepoints) are not supported")
// Tx is a pgx.Tx bound to a host-side transaction handle. Every Exec/Query/
// QueryRow carries the handle so the host runs it on the transaction's
// connection; Commit/Rollback release it. After either, the Tx is closed and
// further statements return pgx.ErrTxClosed. The host also drops the handle at
// the call chain's deadline (WO-WZ-007), so a guest that leaks a Tx without
// committing has it rolled back host-side — a guest can never pin a connection.
type Tx struct {
t Transport
handle uint64
closed bool
}
// Begin would start a savepoint-backed nested transaction: unsupported.
func (tx *Tx) Begin(ctx context.Context) (pgx.Tx, error) { return nil, errNestedTx }
func (tx *Tx) Commit(ctx context.Context) error {
if tx.closed {
return pgx.ErrTxClosed
}
tx.closed = true
return tx.t.txCommit(ctx, tx.handle)
}
func (tx *Tx) Rollback(ctx context.Context) error {
if tx.closed {
return pgx.ErrTxClosed
}
tx.closed = true
return tx.t.txRollback(ctx, tx.handle)
}
func (tx *Tx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
if tx.closed {
return pgconn.CommandTag{}, pgx.ErrTxClosed
}
return tx.t.exec(ctx, sql, tx.handle, args)
}
func (tx *Tx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
if tx.closed {
return errRows(pgx.ErrTxClosed), pgx.ErrTxClosed
}
resp, err := tx.t.query(ctx, sql, tx.handle, args)
if err != nil {
return errRows(err), err
}
return newPgxRows(resp), nil
}
func (tx *Tx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
if tx.closed {
return &pgxRow{err: pgx.ErrTxClosed}
}
resp, err := tx.t.query(ctx, sql, tx.handle, args)
if err != nil {
return &pgxRow{err: err}
}
return &pgxRow{rows: newPgxRows(resp)}
}
// --- Unsupported pgx.Tx surface (not emitted by sqlc; explicit errors) ---
func (tx *Tx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) {
return 0, errors.New("bnwasm: CopyFrom is not supported over the wasm DB ABI")
}
func (tx *Tx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults {
return errBatchResults{errors.New("bnwasm: SendBatch is not supported over the wasm DB ABI")}
}
func (tx *Tx) LargeObjects() pgx.LargeObjects { return pgx.LargeObjects{} }
func (tx *Tx) Prepare(context.Context, string, string) (*pgconn.StatementDescription, error) {
return nil, errors.New("bnwasm: Prepare is not supported over the wasm DB ABI")
}
func (tx *Tx) Conn() *pgx.Conn { return nil }
// errBatchResults is a pgx.BatchResults that reports the same error from every
// method, so an unsupported SendBatch surfaces cleanly instead of nil-panicking.
type errBatchResults struct{ err error }
func (e errBatchResults) Exec() (pgconn.CommandTag, error) { return pgconn.CommandTag{}, e.err }
func (e errBatchResults) Query() (pgx.Rows, error) { return errRows(e.err), e.err }
func (e errBatchResults) QueryRow() pgx.Row { return &pgxRow{err: e.err} }
func (e errBatchResults) Close() error { return e.err }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@

[]

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

@ -0,0 +1 @@


View File

@ -0,0 +1,2 @@

soft 

View File

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

View File

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

View File

@ -0,0 +1 @@


View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@

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

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