feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)

Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 10:51:00 +08:00
parent 807f713f3d
commit b6d40ed8ac
253 changed files with 33998 additions and 1 deletions

40
AGENTS.md Normal file
View File

@ -0,0 +1,40 @@
# Plugin SDK
Go module `git.dev.alexdunmow.com/block/pluginsdk`.
**Purpose: this is the ONE plugin-facing artifact.** BlockNinja CMS plugins
import this module and nothing else from the internal Go tree. Plugins must
NOT import `block/core` — core exists only for code shared between first-party
entities (cms, orchestrator, ninja CLI).
## The contract is proto, not Go
- `abi/proto/v1/*.proto` is **the contract**. The host↔guest wire is proto-only;
a plugin in ANY language generates its own bindings from these files.
- `abi/v1/` is the **Go** binding of that contract (`abiv1`). The rest of the Go
surface here — `plugin/`, `plugin/wasmguest/**`, and the guest-facing type
packages — is **one language binding** of the SDK. A non-Go plugin implements
the ABI hooks directly and never sees these Go structs.
## Critical Rules
- **NEVER use `replace` directives in go.mod** — not here, not in any consumer.
All module resolution goes through the Gitea module proxy. To test local
changes, tag and push a version.
- All consumers are in-house — **no backwards-compatibility shims**. Change the
API and update consumers.
- Regenerate Go bindings after editing any `.proto`: `make proto`.
## `templates/bn/` is a synced copy — do not author here
The bn page chrome (head / toolbar / engagement / asset_hooks) is **authored in
`cms/backend/templates/bn`** and mechanically copied here via cms `make
sync-templates`, solely so guest-side plugin templates can compile it into their
wasm. check-safety **check 31** fails cms commits while the copies drift. Never
edit these files here directly — change them in cms and sync. (This chrome is
transitional: it is deleted once host-side head/body injection lands — Phase 3.)
## Design
Program spec (in the cms repo):
`docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md`.

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

21
Makefile Normal file
View File

@ -0,0 +1,21 @@
SDK_MODULE := git.dev.alexdunmow.com/block/pluginsdk
# Lint + regenerate the Go bindings for the wasm plugin ABI from the proto
# tree (abi/proto/v1). go_package is set explicitly in each .proto; with
# paths=source_relative and out: ., v1/*.proto emits to abi/v1/*.pb.go, i.e.
# Go package git.dev.alexdunmow.com/block/pluginsdk/abi/v1 (abiv1).
.PHONY: proto
proto:
cd abi && buf lint && buf generate
.PHONY: build
build:
go build ./...
.PHONY: test
test:
go test ./...
.PHONY: tidy
tidy:
go mod tidy

View File

@ -1 +1,46 @@
# BlockNinja Plugin SDK (block/pluginsdk) # block/pluginsdk
The **plugin-facing SDK** for BlockNinja CMS plugins. Plugins import THIS module,
never `block/core`.
## What lives here
- **`abi/proto/v1/`** — THE single source-of-truth ABI proto tree for the wasm
plugin contract. The wire between host and guest is proto-only; any language
can generate its own bindings from these `.proto` files. (This tree used to be
duplicated by hand in `cms/backend/abi` and `core/abi`; it now lives here once.)
- **`abi/v1/`** — the generated Go bindings (`abiv1`). Regenerate with `make proto`.
- **`plugin/`** — the Go registration + DI surface (`PluginRegistration`,
`CoreServices`/`ServiceDeps`, block/template registries) and the minimal Go
wasm transport shim under `plugin/wasmguest/` (exports, dispatch, hostcalls,
context rehydration), its capability stubs (`plugin/wasmguest/caps/`), and the
`database/sql` driver over the host DB (`plugin/wasmguest/bnwasm/`).
- **Guest-facing type packages** plugins consume: `blocks/` (incl. `blocks/builtin`,
`blocks/shared`, `blocks/tags`), `templates/` (registry + `templates/pongo`),
`templates/bn/` (the templ chrome — see sync rule below), `auth/`, `settings/`,
`content/`, `gating/`, `crypto/`, `rbac/`, `video/`, `ai/`, `subscriptions/`,
`menus/`, `datasources/`.
The Go surface here is **one language binding**. The proto tree is the contract:
a non-Go plugin implements the ABI hooks directly and never sees the Go structs.
## `templates/bn` synced-copy rule
`templates/bn/*` is **authored in the cms repo** (`cms/backend/templates/bn`) and
**synced into here** via cms `make sync-templates`. Do not hand-edit the copy in
this repo — edit it in cms and re-sync. Drift is enforced by check-safety **check 31**
(`check-safety/check_templatesync.go`), which compares this repo's `templates/bn`
against the cms authoritative copy. (This chrome is transitional: it is deleted from
the SDK once host-side head/body injection lands — Phase 3.)
## Rules
- **NEVER use `replace` directives** in `go.mod`. Module resolution goes through the
Gitea module proxy — to test local changes, tag and push a version.
- All consumers are in-house — **no backwards-compatibility shims**.
- Plugins import `block/pluginsdk/...`, never `block/core/...`.
## Design
Program spec (in the cms repo):
`docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md`.

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/pluginsdk/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,844 @@
// 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/pluginsdk/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
string body = 7; // rendered HTML; set by get_post / list_posts(include_body)
string author_name = 8;
string author_slug = 9;
google.protobuf.Timestamp published_at = 10;
}
message ContentListPostsRequest {
int32 limit = 1;
int32 offset = 2;
string category = 3; // optional category slug filter ("" = all)
bool published_only = 4;
bool include_body = 5;
bool include_excerpt = 6;
}
message ContentListPostsResponse {
repeated PostInfo posts = 1;
}
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 {}
// --- content.* writes (content.Author, WO-WZ-019) ---
//
// Imperative page/post authoring. The idempotent seed-time counterparts live
// in the provisioner.* family below; these are for runtime authoring and for
// the WO-WZ-020 codeless seed runner.
message ContentCreatePageRequest {
string slug = 1;
string parent_slug = 2; // "" = root
string title = 3;
string template_key = 4;
string master_page_key = 5; // "" = none
}
message ContentCreatePageResponse {
string page_id = 1; // UUID
// False when a page with this slug already existed (the existing ID is
// returned and nothing is modified).
bool created = 2;
}
// PageBlock is one block instance placed on a page (mirrors
// plugin.PageBlockConfig / MasterPageBlock).
message PageBlock {
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;
}
message ContentSetPageBlocksRequest {
string page_id = 1; // UUID
// Full replacement set for the page's draft document blocks.
repeated PageBlock blocks = 2;
}
message ContentSetPageBlocksResponse {}
message ContentPublishPageRequest {
string page_id = 1; // UUID
}
message ContentPublishPageResponse {}
// ContentSetPageSeoRequest mirrors the CMS UpdatePageSEO surface. Unset
// optionals leave the existing value untouched.
message ContentSetPageSeoRequest {
string page_id = 1; // UUID
optional string meta_title = 2;
optional string meta_description = 3;
optional string og_title = 4;
optional string og_description = 5;
optional string og_image = 6;
optional string focus_keyphrase = 7;
optional string canonical_url = 8;
optional string robots_directive = 9;
optional string twitter_title = 10;
optional string twitter_description = 11;
optional string twitter_image = 12;
}
message ContentSetPageSeoResponse {}
// ContentUpsertPostRequest creates or updates a blog post by slug (posts live
// in the dedicated blog_posts table).
message ContentUpsertPostRequest {
string slug = 1;
string title = 2;
// JSON encoding of the BlockNote document.
bytes document_json = 3;
optional string excerpt = 4;
optional string author_profile_id = 5; // UUID
optional string featured_image_id = 6; // UUID (e.g. from media.deposit)
// Publish immediately after the upsert.
bool publish = 7;
bool is_featured = 8;
}
message ContentUpsertPostResponse {
string post_id = 1; // UUID
bool created = 2;
}
// --- provisioner.* (plugin.Provisioner, WO-WZ-019) ---
//
// Idempotent seed/ensure operations, 1:1 with the plugin.Provisioner Go
// interface. In the wasm world these are LOAD-TIME capabilities: plugins call
// deps.Provisioner from their Load hook (RegisterWithProvisioner cannot cross
// the DESCRIBE boundary host functions are stubbed there). All methods
// check-then-create; re-running them is safe.
//
// EmbedConfig.RenderFunc deliberately does not cross: an ABI-provisioned
// embed is template-rendered host-side. A compute-rendered embed needs a
// block + hook instead.
message ProvisionerEnsureDataTableRequest {
string key = 1;
string name = 2;
string description = 3;
// JSON schema of the table (json.RawMessage in DataTableConfig).
bytes schema_json = 4;
string primary_key = 5;
}
message ProvisionerEnsureDataTableResponse {}
message ProvisionerMergeSiteSettingsRequest {
// JSON encoding of the defaults map; existing keys win.
bytes defaults_json = 1;
}
message ProvisionerMergeSiteSettingsResponse {}
message ProvisionerEnsureSettingRequest {
string key = 1;
// JSON encoding of the default value.
bytes default_value_json = 2;
}
message ProvisionerEnsureSettingResponse {}
// PageSeed mirrors plugin.PageConfig.
message PageSeed {
string slug = 1;
string parent_slug = 2;
string title = 3;
string template_key = 4;
repeated PageBlock blocks = 5;
string detail_source_type = 6;
string detail_source_key = 7;
string detail_slug_field = 8;
bool reconcile_blocks = 9;
bool reconcile_template = 10;
}
message ProvisionerEnsurePageRequest {
PageSeed page = 1;
}
message ProvisionerEnsurePageResponse {}
message ProvisionerOverrideSiteSettingsRequest {
// JSON encoding of the overrides map; plugin values win.
bytes overrides_json = 1;
}
message ProvisionerOverrideSiteSettingsResponse {}
// ProvisionerEnsureMenuItemRequest mirrors plugin.MenuItemConfig under a
// named menu.
message ProvisionerEnsureMenuItemRequest {
string menu_name = 1;
string label = 2;
string url = 3;
string page_slug = 4;
int32 sort_order = 5;
}
message ProvisionerEnsureMenuItemResponse {}
// ProvisionerRegisterEmbeddingConfigRequest mirrors plugin.EmbeddingConfigDef.
message ProvisionerRegisterEmbeddingConfigRequest {
string table_key = 1;
string text_template = 2;
bool enabled = 3;
}
message ProvisionerRegisterEmbeddingConfigResponse {}
// ProvisionerEnsureEmbedRequest mirrors plugin.EmbedConfig minus RenderFunc
// (function values cannot cross; the Template renders host-side).
message ProvisionerEnsureEmbedRequest {
string key = 1;
string title = 2;
string description = 3;
string icon = 4;
string label_field = 5;
string template = 6;
string data_source_type = 7; // EmbedDataSource.Type
string data_source_table_key = 8; // EmbedDataSource.TableKey
}
message ProvisionerEnsureEmbedResponse {}
// ProvisionerEnsureJobScheduleRequest mirrors plugin.JobScheduleConfig.
message ProvisionerEnsureJobScheduleRequest {
string job_type = 1;
string cron_expression = 2;
// JSON job configuration.
bytes config_json = 3;
}
message ProvisionerEnsureJobScheduleResponse {}
message ProvisionerUpdateDataTableRowFieldRequest {
string row_id = 1; // UUID
string field_key = 2;
// JSON encoding of the value.
bytes value_json = 3;
}
message ProvisionerUpdateDataTableRowFieldResponse {}
message ProvisionerDisableOrphanedJobSchedulesRequest {
repeated string registered_types = 1;
}
message ProvisionerDisableOrphanedJobSchedulesResponse {}
message ProvisionerEnsurePluginRequest {
string name = 1;
}
message ProvisionerEnsurePluginResponse {}
// ProvisionerEnsureCustomColorRequest mirrors plugin.CustomColorConfig.
message ProvisionerEnsureCustomColorRequest {
string name = 1;
string light_value = 2;
string dark_value = 3;
string source = 4;
}
message ProvisionerEnsureCustomColorResponse {}
// ProvisionerEnsureMediaRequest mirrors plugin.MediaDeposit with a REQUIRED
// deterministic id (the template-referable key). Idempotent: an existing id
// is a no-op; changed bytes warn rather than overwrite.
message ProvisionerEnsureMediaRequest {
string id = 1; // UUID, required
string filename = 2;
bytes data = 3;
string alt_text = 4;
string folder = 5;
string source = 6;
}
message ProvisionerEnsureMediaResponse {}
// --- settings.update_plugin_settings (settings.Updater, WO-WZ-019) ---
// SettingsUpdatePluginSettingsRequest replaces the calling plugin's own
// settings map. The host binds the target plugin from the caller's identity;
// plugin_name is advisory and MUST match it (PERMISSION_DENIED otherwise).
message SettingsUpdatePluginSettingsRequest {
string plugin_name = 1;
// JSON encoding of the full settings map.
bytes settings_json = 2;
}
message SettingsUpdatePluginSettingsResponse {}
// --- jobs.progress (plugin.JobHandlerFunc progress callback, WO-WZ-019) ---
// JobsProgressRequest reports progress for the CURRENTLY EXECUTING job. The
// host correlates it to the job via the invoking HOOK_JOB call's context, so
// it is only meaningful while a JOB hook is on the stack; outside one it is
// accepted and discarded.
message JobsProgressRequest {
int32 current = 1;
int32 total = 2;
string message = 3;
}
message JobsProgressResponse {}
// --- bridge.invoke (plugin.PluginBridge cross-plugin calls, WO-WZ-019) ---
// BridgeInvokeRequest calls a method on another plugin's registered bridge
// service. The provider answers via HOOK_BRIDGE_CALL (wasm) or an in-process
// plugin.BridgeInvokable (bundled). Payloads are opaque bytes the two
// plugins agree on the encoding (JSON by convention).
message BridgeInvokeRequest {
string plugin_name = 1;
string service_name = 2;
string method = 3;
bytes payload = 4;
}
message BridgeInvokeResponse {
bytes payload = 1;
}

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

@ -0,0 +1,119 @@
// 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/pluginsdk/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;
// uuid[] array (each element canonical string form). Distinct from
// text_array so the host binds a native uuid[] parameter (letting a query
// keep `ANY($1::uuid[])` with no text[]-cast workaround) and scans a uuid[]
// column straight into []uuid.UUID.
UuidArray uuid_array_value = 12;
}
}
// TextArray is a Postgres text[] value.
message TextArray {
repeated string values = 1;
}
// UuidArray is a Postgres uuid[] value; each element is a canonical UUID
// string (matching DbValue.uuid_value's single-UUID encoding).
message UuidArray {
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/pluginsdk/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;
}

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

@ -0,0 +1,278 @@
// 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/pluginsdk/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;
// Invoke a plugin-declared template tag (manifest.declared_tags) that the
// host engine hit while rendering a powered block:
// payload = RenderTagRequest / RenderTagResponse.
HOOK_RENDER_TAG = 10;
// Apply a plugin-declared template filter (manifest.declared_filters):
// payload = ApplyFilterRequest / ApplyFilterResponse.
HOOK_APPLY_FILTER = 11;
// Execute a guest-registered AI tool handler (ai.tools.register):
// payload = AiToolCallRequest / AiToolCallResponse.
HOOK_AI_TOOL_CALL = 12;
// Invoke a method on a bridge service this plugin registered
// (bridge.register_service): payload = BridgeCallRequest / BridgeCallResponse.
HOOK_BRIDGE_CALL = 13;
// Render one directory panel section (DirectoryExtensions.PanelSections):
// payload = DirectoryPanelSectionRequest / DirectoryPanelSectionResponse.
HOOK_DIRECTORY_PANEL_SECTION = 14;
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
HOOK_DIRECTORY_PIN_DECORATOR = 15;
}
// 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;
// A db.* call named a transaction handle the host has already expired
// (dropped at the call-chain deadline, WO-WZ-007). Distinct from a real
// fault: the unit of work is retryable in a fresh transaction. Emitted by
// the cms dbexec side (adopted separately) and mapped guest-side to
// bnwasm.ErrTxExpired.
ABI_ERROR_CODE_TX_EXPIRED = 6;
}
// 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;
}
// --- AI_TOOL_CALL ---
// AiToolCallRequest executes the guest-side Handler of a tool the plugin
// registered via ai.tools.register (ai.ToolHandler).
message AiToolCallRequest {
string slug = 1;
// JSON encoding of the params map.
bytes params_json = 2;
}
// AiToolCallResponse mirrors ai.ToolResult. A handler-level failure travels
// in error_message (a normal tool outcome the model sees); AbiError stays
// reserved for transport/decode/panic failures.
message AiToolCallResponse {
string content = 1;
string error_message = 2;
}
// --- BRIDGE_CALL ---
// BridgeCallRequest asks THIS plugin (the provider) to run a method on one of
// its registered bridge services. The consumer side is the bridge.invoke
// capability; payload encoding is agreed between the two plugins (JSON by
// convention).
message BridgeCallRequest {
string service_name = 1;
string method = 2;
bytes payload = 3;
}
message BridgeCallResponse {
bytes payload = 1;
}
// --- DIRECTORY_PANEL_SECTION / DIRECTORY_PIN_DECORATOR ---
// DirectoryPanelSectionRequest renders the index-th registered panel section
// (DirectoryExtensions.PanelSections[index], counted in
// manifest.directory_extensions.panel_section_count).
message DirectoryPanelSectionRequest {
uint32 index = 1;
// JSON encoding of the row data map.
bytes data_json = 2;
}
message DirectoryPanelSectionResponse {
string html = 1;
}
// DirectoryPinDecoratorRequest runs the index-th registered pin decorator,
// which mutates the pin map in place; the mutated pin is returned.
message DirectoryPinDecoratorRequest {
uint32 index = 1;
// JSON encoding of the pin map (mutated by the decorator).
bytes pin_json = 2;
// JSON encoding of the row data map (read-only input).
bytes data_json = 3;
}
message DirectoryPinDecoratorResponse {
// JSON encoding of the mutated pin map.
bytes pin_json = 1;
}
// 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;
}

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

@ -0,0 +1,243 @@
// 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/pluginsdk/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;
// data_dir requests a persistent per-plugin /data preopen at load. Its
// source of truth is the plugin.mod `data_dir = true` key, which lives
// outside the guest code, so DESCRIBE cannot populate it: the packer
// (`ninja plugin build`) stamps it into the manifest from plugin.mod so
// the loader reads one source. OFF by default.
bool data_dir = 30;
// Template tags the plugin provides (blocks.RegisterTag). For each name the
// host registers a pongo2 tag that invokes the guest via HOOK_RENDER_TAG.
// Captured by DESCRIBE from the blocks tag registry (Register-time).
repeated string declared_tags = 31;
// Template filters the plugin provides (blocks.RegisterFilter). For each
// name the host registers a pongo2 filter that invokes the guest via
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
repeated string declared_filters = 32;
// codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020):
// the host runs it entirely block definitions from the artifact's
// blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/,
// assets/migrations as usual and never instantiates a guest. A codeless
// manifest MUST NOT declare any computing hook (http handler, jobs,
// load/unload, RAG fetchers, media hooks, tags/filters, provisioner,
// service bindings, directory callbacks); the packer and the host reader
// both reject the combination. Not produced by DESCRIBE (there is no guest
// to describe): `ninja plugin build` synthesizes the manifest from
// plugin.mod + the artifact's declarative files.
bool codeless = 33;
}
// 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;
}

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

@ -0,0 +1,259 @@
// 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/pluginsdk/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 {
// Final rendered HTML for a plain (non-template) block. Ignored when
// `powered` is set.
string html = 1;
// Set when the block is "powered": instead of final HTML, the block hands
// back a template string + data map (blocks.PoweredBlock) and the HOST
// renders it (pongo2/ninjatpl, host-side) AFTER this RENDER_BLOCK call has
// returned. Because the block-invoke has already returned, the guest
// instance is free, so any plugin tag/filter the template hits (RENDER_TAG /
// APPLY_FILTER) is a fresh invoke no re-entrancy. A singular message field
// has explicit presence: nil (GetPowered() == nil) means a plain HTML block.
PoweredBlock powered = 2;
}
// PoweredBlock is a template-backed block result. The host renders `template`
// with `data_json` as the pongo2 data map. See core/docs/wasm-abi.md
// §"Powered blocks (render as a host capability)".
message PoweredBlock {
// ninjatpl/pongo2 template source the host renders host-side.
string template = 1;
// JSON encoding of the template data map (map[string]any).
bytes data_json = 2;
}
// 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;
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
// The host wires one pongo2 tag per manifest.declared_tags name; when its
// engine hits that tag while rendering a powered block, it calls back here.
// Re-entrancy-free: the originating RENDER_BLOCK has already returned.
message RenderTagRequest {
string tag_name = 1;
// JSON encoding of the tag's parsed arguments (map[string]any).
bytes args_json = 2;
// The active render context (same envelope RENDER_BLOCK carries), so the
// tag fn can read page/post/request state via blocks.Get*.
RenderContext render_context = 3;
}
// RenderTagResponse returns the tag's rendered HTML. A non-empty `error`
// carries the tag fn's returned error (distinct from an AbiError, which is
// reserved for transport/dispatch/panic failures).
message RenderTagResponse {
string html = 1;
string error = 2;
}
// ApplyFilterRequest invokes a plugin-declared template filter
// (HOOK_APPLY_FILTER). The host wires one pongo2 filter per
// manifest.declared_filters name.
message ApplyFilterRequest {
string filter_name = 1;
// The value the filter is applied to (pongo2 passes strings).
string input = 2;
// JSON encoding of the filter's arguments (map[string]any).
bytes args_json = 3;
}
// ApplyFilterResponse returns the filtered output. A non-empty `error`
// carries the filter fn's returned error.
message ApplyFilterResponse {
string output = 1;
string error = 2;
}
// 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;
}

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

File diff suppressed because it is too large Load Diff

1128
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\x01B5Z3git.dev.alexdunmow.com/block/pluginsdk/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
}

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

28
ai/tools.go Normal file
View File

@ -0,0 +1,28 @@
package ai
import "context"
// ToolResult holds the output of a tool execution.
type ToolResult struct {
Content string `json:"content"`
Error string `json:"error,omitempty"`
}
// ToolHandler is the function signature for executing a tool.
type ToolHandler func(ctx context.Context, params map[string]any) (*ToolResult, error)
// ToolDefinition describes a registered tool.
type ToolDefinition struct {
Slug string
Name string
Description string
ParameterSchema map[string]any
Handler ToolHandler
}
// ToolRegistry is the interface for registering AI tools.
// Plugins use this to register their custom tools.
// The CMS provides the concrete implementation.
type ToolRegistry interface {
Register(tool *ToolDefinition)
}

18
auth/claims.go Normal file
View File

@ -0,0 +1,18 @@
package auth
import "github.com/google/uuid"
// Claims represents admin user JWT claims.
// Plugins receive these from context — they don't parse JWTs themselves.
type Claims struct {
UserID uuid.UUID
Email string
Role string
}
// PublicClaims represents public/community user JWT claims.
type PublicClaims struct {
UserID uuid.UUID
Email string
Username string
}

34
auth/context.go Normal file
View File

@ -0,0 +1,34 @@
package auth
import "context"
type contextKey string
const (
userContextKey contextKey = "user"
publicUserContextKey contextKey = "public_user"
)
// GetUserFromContext retrieves admin user claims from context.
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(userContextKey).(*Claims)
return claims, ok
}
// GetPublicUserFromContext retrieves public user claims from context.
func GetPublicUserFromContext(ctx context.Context) (*PublicClaims, bool) {
claims, ok := ctx.Value(publicUserContextKey).(*PublicClaims)
return claims, ok
}
// WithUser adds admin user claims to context.
// Used by CMS auth middleware — not typically called by plugins.
func WithUser(ctx context.Context, claims *Claims) context.Context {
return context.WithValue(ctx, userContextKey, claims)
}
// WithPublicUser adds public user claims to context.
// Used by CMS auth middleware — not typically called by plugins.
func WithPublicUser(ctx context.Context, claims *PublicClaims) context.Context {
return context.WithValue(ctx, publicUserContextKey, claims)
}

25
auth/public_users.go Normal file
View File

@ -0,0 +1,25 @@
package auth
import (
"context"
"github.com/google/uuid"
)
// PublicUsers provides public/community user profile access for plugins.
type PublicUsers interface {
GetByUsername(ctx context.Context, username string) (*PublicUserProfile, error)
GetByID(ctx context.Context, id uuid.UUID) (*PublicUserProfile, error)
}
// PublicUserProfile is a plugin-facing view of a public user.
type PublicUserProfile struct {
ID uuid.UUID
Email string
Username string
DisplayName string
AvatarURL string
Bio string
EmailVerified bool
Role string
}

91
auth/trustedheaders.go Normal file
View File

@ -0,0 +1,91 @@
package auth
import (
"context"
"net/http"
"github.com/google/uuid"
)
// Trusted identity headers — the wasm plugin auth contract.
//
// Context values do NOT cross the wasm ABI, so a guest cannot see the auth
// context the host's middleware built. The SECURE way to convey the caller's
// identity to a guest is these headers, populated HOST-SIDE from the
// signature-verified principal after the host has run its RBAC guard
// (rbac.Authorize against the verified JWT). The host STRIPS any client-supplied
// copy of these headers before setting them, so a guest may trust them
// unconditionally.
//
// A guest MUST NOT decode a client-supplied cookie or Authorization/Bearer
// token to establish identity — those are attacker-controlled across the
// boundary (the host forwards the raw request), and trusting them is a
// privilege-escalation bug. Read identity ONLY from these headers (via
// TrustedHeaderMiddleware / ContextFromTrustedHeaders). See
// core/docs/wasm-abi.md ("Trusted identity headers").
//
// Values are the canonical MIME header form so a host map-set and a guest
// http.Header.Get agree without re-canonicalization surprises.
const (
// Admin principal (auth.Claims).
HeaderVerifiedUserID = "X-Bn-Verified-User-Id"
HeaderVerifiedRole = "X-Bn-Verified-Role"
HeaderVerifiedEmail = "X-Bn-Verified-Email"
// Public/community principal (auth.PublicClaims).
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email"
)
// AllTrustedHeaders lists every trusted identity header, canonical form. The
// host deletes each of these from the inbound request before injecting its own
// verified values, so a client cannot forge one.
func AllTrustedHeaders() []string {
return []string{
HeaderVerifiedUserID,
HeaderVerifiedRole,
HeaderVerifiedEmail,
HeaderVerifiedPublicUserID,
HeaderVerifiedPublicUsername,
HeaderVerifiedPublicEmail,
}
}
// ContextFromTrustedHeaders rebuilds the request's auth context from the host's
// verified identity headers. Trust model: see the const block above — these
// headers are host-controlled, so no token parsing or signature check happens
// here (the guest has no signing secret by design). A malformed user-id header
// is ignored rather than trusted.
func ContextFromTrustedHeaders(ctx context.Context, h http.Header) context.Context {
if s := h.Get(HeaderVerifiedUserID); s != "" {
if id, err := uuid.Parse(s); err == nil {
ctx = WithUser(ctx, &Claims{
UserID: id,
Email: h.Get(HeaderVerifiedEmail),
Role: h.Get(HeaderVerifiedRole),
})
}
}
if s := h.Get(HeaderVerifiedPublicUserID); s != "" {
if id, err := uuid.Parse(s); err == nil {
ctx = WithPublicUser(ctx, &PublicClaims{
UserID: id,
Email: h.Get(HeaderVerifiedPublicEmail),
Username: h.Get(HeaderVerifiedPublicUsername),
})
}
}
return ctx
}
// TrustedHeaderMiddleware wraps a guest HTTP handler so downstream RPCs can read
// auth.GetUserFromContext / GetPublicUserFromContext. Plugins whose Connect RPCs
// resolve the caller from context MUST mount this around their handler (context
// does not cross the ABI otherwise). This is the ONLY sanctioned way for a guest
// to learn the caller's identity.
func TrustedHeaderMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(ContextFromTrustedHeaders(r.Context(), r.Header)))
})
}

View File

@ -0,0 +1,82 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
func TestContextFromTrustedHeaders(t *testing.T) {
adminID := uuid.New()
pubID := uuid.New()
h := http.Header{}
h.Set(HeaderVerifiedUserID, adminID.String())
h.Set(HeaderVerifiedRole, "superadmin")
h.Set(HeaderVerifiedEmail, "admin@example.com")
h.Set(HeaderVerifiedPublicUserID, pubID.String())
h.Set(HeaderVerifiedPublicUsername, "ninja")
h.Set(HeaderVerifiedPublicEmail, "ninja@example.com")
ctx := ContextFromTrustedHeaders(context.Background(), h)
c, ok := GetUserFromContext(ctx)
if !ok {
t.Fatal("admin claims missing")
}
if c.UserID != adminID || c.Role != "superadmin" || c.Email != "admin@example.com" {
t.Fatalf("admin claims mismatch: %+v", c)
}
p, ok := GetPublicUserFromContext(ctx)
if !ok {
t.Fatal("public claims missing")
}
if p.UserID != pubID || p.Username != "ninja" || p.Email != "ninja@example.com" {
t.Fatalf("public claims mismatch: %+v", p)
}
}
// TestContextFromTrustedHeaders_NoneWhenAbsent proves an anonymous request (no
// trusted headers) yields no principals — nothing is fabricated.
func TestContextFromTrustedHeaders_NoneWhenAbsent(t *testing.T) {
ctx := ContextFromTrustedHeaders(context.Background(), http.Header{})
if _, ok := GetUserFromContext(ctx); ok {
t.Fatal("unexpected admin claims for anonymous request")
}
if _, ok := GetPublicUserFromContext(ctx); ok {
t.Fatal("unexpected public claims for anonymous request")
}
}
// TestContextFromTrustedHeaders_BadUUIDIgnored proves a malformed id header is
// ignored rather than trusted (no panic, no principal).
func TestContextFromTrustedHeaders_BadUUIDIgnored(t *testing.T) {
h := http.Header{}
h.Set(HeaderVerifiedUserID, "not-a-uuid")
h.Set(HeaderVerifiedRole, "superadmin")
ctx := ContextFromTrustedHeaders(context.Background(), h)
if _, ok := GetUserFromContext(ctx); ok {
t.Fatal("malformed user-id header must not yield a principal")
}
}
// TestTrustedHeaderMiddleware proves the middleware installs the principal into
// the downstream handler's request context, keyed by the canonical header form.
func TestTrustedHeaderMiddleware(t *testing.T) {
adminID := uuid.New()
var got *Claims
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, _ = GetUserFromContext(r.Context())
})
req := httptest.NewRequest(http.MethodGet, "/x", nil)
// lower-case on the way in — net/http canonicalizes, middleware must still see it.
req.Header.Set("x-bn-verified-user-id", adminID.String())
req.Header.Set("x-bn-verified-role", "admin")
TrustedHeaderMiddleware(next).ServeHTTP(httptest.NewRecorder(), req)
if got == nil || got.UserID != adminID || got.Role != "admin" {
t.Fatalf("middleware did not install principal: %+v", got)
}
}

299
blocks/builtin/html.go Normal file
View File

@ -0,0 +1,299 @@
package builtin
import (
"context"
"fmt"
"html"
"maps"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"github.com/google/uuid"
)
var HTMLBlockMeta = blocks.BlockMeta{
Key: "html",
Title: "HTML",
Description: "Raw HTML content with template variable support",
Category: blocks.CategoryContent,
}
var mediaURLRegex = regexp.MustCompile(`(src|href)=["']media:([^"']+)["']`)
func HTMLBlock(ctx context.Context, content map[string]any) string {
var htmlTemplate string
if htmlContent, ok := content["_html_content"].(string); ok && htmlContent != "" {
htmlTemplate = htmlContent
} else {
htmlTemplate, _ = content["html"].(string)
}
if htmlTemplate == "" {
return ""
}
enrichedContent := enrichMediaFields(ctx, content)
result, err := blocks.RenderTemplate(htmlTemplate, enrichedContent)
if err != nil {
if blocks.IsEditor(ctx) {
return fmt.Sprintf(`<div class="bg-red-50 border border-red-200 text-red-700 p-4 rounded">
<strong>Template Error:</strong> %s
</div>`, html.EscapeString(err.Error()))
}
return ""
}
result = blocks.ProcessIcons(result)
result = ResolveMediaURLs(result)
return result
}
func enrichMediaFields(ctx context.Context, content map[string]any) map[string]any {
mediaFields := GetMediaFields(content)
if len(mediaFields) == 0 {
return content
}
resolver := blocks.GetMediaResolver(ctx)
enriched := make(map[string]any, len(content))
maps.Copy(enriched, content)
for fieldName := range mediaFields {
value, ok := content[fieldName].(string)
if !ok || value == "" {
continue
}
mediaValue := parseMediaValue(ctx, resolver, value)
if mediaValue != nil {
enriched[fieldName] = mediaValue
}
}
return enriched
}
func parseMediaValue(ctx context.Context, resolver blocks.MediaResolver, value string) *blocks.MediaValue {
if value == "" {
return nil
}
path := value
if after, ok := strings.CutPrefix(value, "media:"); ok {
path = after
}
src := path
if !strings.HasPrefix(path, "/media/") {
src = "/media/" + path
}
parts := strings.SplitN(path, "/", 2)
if len(parts) == 0 || parts[0] == "" {
return &blocks.MediaValue{Src: src}
}
mediaID, err := uuid.Parse(parts[0])
if err != nil {
return &blocks.MediaValue{Src: src}
}
if resolver == nil {
return &blocks.MediaValue{Src: src}
}
resolved := resolver.ResolveMedia(ctx, mediaID)
if resolved == nil {
return &blocks.MediaValue{Src: src}
}
resolved.Src = src
return resolved
}
// GetMediaFields extracts field names that have x-editor: "media" from customSchema.
func GetMediaFields(content map[string]any) map[string]bool {
result := make(map[string]bool)
customSchema, ok := content["customSchema"].(map[string]any)
if !ok {
return result
}
properties, ok := customSchema["properties"].(map[string]any)
if !ok {
return result
}
for key, prop := range properties {
propMap, ok := prop.(map[string]any)
if !ok {
continue
}
if editor, ok := propMap["x-editor"].(string); ok && editor == "media" {
result[key] = true
}
}
return result
}
// ResolveMediaURLs converts media: prefixed URLs to proper /media/ paths.
func ResolveMediaURLs(htmlStr string) string {
return mediaURLRegex.ReplaceAllStringFunc(htmlStr, func(match string) string {
submatches := mediaURLRegex.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
attr := submatches[1]
path := submatches[2]
if strings.HasPrefix(path, "/media/") {
return fmt.Sprintf(`%s="%s"`, attr, path)
}
return fmt.Sprintf(`%s="/media/%s"`, attr, path)
})
}
var (
boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
italicRegex = regexp.MustCompile(`\*([^*]+?)\*`)
colorRegex = regexp.MustCompile(`==(?:([a-z-]+):)?(.+?)==`)
linkRegex = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
)
var validThemeColors = map[string]bool{
"foreground": true, "background": true,
"primary": true, "primary-foreground": true,
"secondary": true, "secondary-foreground": true,
"muted": true, "muted-foreground": true,
"accent": true, "accent-foreground": true,
"destructive": true, "destructive-foreground": true,
"border": true, "card": true, "card-foreground": true,
}
// ProcessMarkdown converts markdown-like syntax to HTML.
func ProcessMarkdown(text string) string {
text = boldRegex.ReplaceAllString(text, `<strong>$1</strong>`)
text = italicRegex.ReplaceAllString(text, `<em>$1</em>`)
text = colorRegex.ReplaceAllStringFunc(text, func(match string) string {
submatches := colorRegex.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
colorName := submatches[1]
content := submatches[2]
if colorName == "" || !validThemeColors[colorName] {
colorName = "accent"
}
return fmt.Sprintf(`<span class="text-%s">%s</span>`, colorName, content)
})
text = linkRegex.ReplaceAllStringFunc(text, func(match string) string {
submatches := linkRegex.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
linkText := submatches[1]
url := submatches[2]
url = strings.ReplaceAll(url, "&amp;", "&")
url = strings.ReplaceAll(url, "&#34;", "\"")
url = strings.ReplaceAll(url, "&#39;", "'")
return fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(url), linkText)
})
return text
}
// WrapLinesInParagraphs converts textarea newlines to <br> tags.
func WrapLinesInParagraphs(text string) string {
return strings.ReplaceAll(text, "\n", "<br>")
}
// GetTextareaFields extracts field names that have x-editor: textarea from customSchema.
func GetTextareaFields(content map[string]any) map[string]bool {
result := make(map[string]bool)
customSchema, ok := content["customSchema"].(map[string]any)
if !ok {
return result
}
properties, ok := customSchema["properties"].(map[string]any)
if !ok {
return result
}
for key, prop := range properties {
propMap, ok := prop.(map[string]any)
if !ok {
continue
}
if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
result[key] = true
}
}
return result
}
// GetArrayItemTextareaFields extracts textarea field names from an array's item schema.
func GetArrayItemTextareaFields(content map[string]any, collectionName string) map[string]bool {
result := make(map[string]bool)
customSchema, ok := content["customSchema"].(map[string]any)
if !ok {
return result
}
properties, ok := customSchema["properties"].(map[string]any)
if !ok {
return result
}
arrayField, ok := properties[collectionName].(map[string]any)
if !ok {
return result
}
if itemProperties, ok := arrayField["itemProperties"].(map[string]any); ok {
for _, prop := range itemProperties {
propMap, ok := prop.(map[string]any)
if !ok {
continue
}
if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
if title, ok := propMap["title"].(string); ok && title != "" {
result[title] = true
}
}
}
return result
}
items, ok := arrayField["items"].(map[string]any)
if !ok {
return result
}
itemProps, ok := items["properties"].(map[string]any)
if !ok {
return result
}
for key, prop := range itemProps {
propMap, ok := prop.(map[string]any)
if !ok {
continue
}
if editor, ok := propMap["x-editor"].(string); ok && editor == "textarea" {
result[key] = true
}
}
return result
}

469
blocks/context.go Normal file
View File

@ -0,0 +1,469 @@
package blocks
import (
"context"
"net/http"
"time"
"github.com/google/uuid"
)
// --- Typed context structs ---
// PageContext provides page information to blocks via context.
// The CMS populates this from its internal db.Page type.
type PageContext struct {
ID uuid.UUID
Slug string
Title string
PostType string // "page", "post", "master", "system"
Status string // "published", "draft", "scheduled"
}
// PostContext provides blog post information to blocks via context.
type PostContext struct {
ID uuid.UUID
Slug string
Title string
Excerpt string
FeaturedImageURL string
AuthorID uuid.UUID
PublishedAt time.Time
ReadingTime int
IsFeatured bool
}
// AuthorContext provides author information to blocks via context.
type AuthorContext struct {
ID uuid.UUID
Name string
Slug string
Bio string
AvatarURL string
}
// CategoryContext provides category information to blocks via context.
type CategoryContext struct {
ID uuid.UUID
Name string
Slug string
}
// MasterPageContext provides master page information during rendering.
type MasterPageContext struct {
ID uuid.UUID
Slug string
Title string
}
// EmbedResolver resolves embedded component blocks within blog content.
type EmbedResolver interface {
RenderEmbed(ctx context.Context, blockID uuid.UUID, dataSource, layout string) string
}
// --- Template key ---
type templateKeyContextKey struct{}
func WithTemplateKey(ctx context.Context, templateKey string) context.Context {
return context.WithValue(ctx, templateKeyContextKey{}, templateKey)
}
func GetTemplateKey(ctx context.Context) string {
if v, ok := ctx.Value(templateKeyContextKey{}).(string); ok {
return v
}
return ""
}
// --- Queries (generic) ---
type queriesContextKey struct{}
// WithQueries stores a queries value in context. Use with GetQueries[T].
func WithQueries[T any](ctx context.Context, queries T) context.Context {
return context.WithValue(ctx, queriesContextKey{}, queries)
}
// GetQueries retrieves a typed queries value from context.
// Usage: queries, ok := blocks.GetQueries[*db.Queries](ctx)
func GetQueries[T any](ctx context.Context) (T, bool) {
val, ok := ctx.Value(queriesContextKey{}).(T)
return val, ok
}
// --- Current page ---
type currentPageContextKey struct{}
func WithCurrentPage(ctx context.Context, page *PageContext) context.Context {
return context.WithValue(ctx, currentPageContextKey{}, page)
}
func GetCurrentPage(ctx context.Context) *PageContext {
if v, ok := ctx.Value(currentPageContextKey{}).(*PageContext); ok {
return v
}
return nil
}
// --- Current blog post ---
type currentBlogPostContextKey struct{}
func WithCurrentBlogPost(ctx context.Context, post *PostContext) context.Context {
return context.WithValue(ctx, currentBlogPostContextKey{}, post)
}
func GetCurrentBlogPost(ctx context.Context) *PostContext {
if v, ok := ctx.Value(currentBlogPostContextKey{}).(*PostContext); ok {
return v
}
return nil
}
// --- Current author ---
type currentAuthorContextKey struct{}
func WithCurrentAuthor(ctx context.Context, author *AuthorContext) context.Context {
return context.WithValue(ctx, currentAuthorContextKey{}, author)
}
func GetCurrentAuthor(ctx context.Context) *AuthorContext {
if v, ok := ctx.Value(currentAuthorContextKey{}).(*AuthorContext); ok {
return v
}
return nil
}
// --- Current category ---
type currentCategoryContextKey struct{}
func WithCurrentCategory(ctx context.Context, category *CategoryContext) context.Context {
return context.WithValue(ctx, currentCategoryContextKey{}, category)
}
func GetCurrentCategory(ctx context.Context) *CategoryContext {
if v, ok := ctx.Value(currentCategoryContextKey{}).(*CategoryContext); ok {
return v
}
return nil
}
// --- Requested path (for 404 pages) ---
type requestedPathContextKey struct{}
func WithRequestedPath(ctx context.Context, path string) context.Context {
return context.WithValue(ctx, requestedPathContextKey{}, path)
}
func GetRequestedPath(ctx context.Context) string {
if v, ok := ctx.Value(requestedPathContextKey{}).(string); ok {
return v
}
return ""
}
// --- Injected slots (master page rendering) ---
type injectedSlotsContextKey struct{}
func WithInjectedSlots(ctx context.Context, slots map[string]string) context.Context {
return context.WithValue(ctx, injectedSlotsContextKey{}, slots)
}
func GetInjectedSlotContent(ctx context.Context, slotName string) string {
if slots, ok := ctx.Value(injectedSlotsContextKey{}).(map[string]string); ok {
return slots[slotName]
}
return ""
}
func GetInjectedSlots(ctx context.Context) map[string]string {
if slots, ok := ctx.Value(injectedSlotsContextKey{}).(map[string]string); ok {
return slots
}
return nil
}
// --- Master page ---
type masterPageContextKey struct{}
func WithMasterPage(ctx context.Context, masterPage *MasterPageContext) context.Context {
return context.WithValue(ctx, masterPageContextKey{}, masterPage)
}
func GetMasterPage(ctx context.Context) *MasterPageContext {
if v, ok := ctx.Value(masterPageContextKey{}).(*MasterPageContext); ok {
return v
}
return nil
}
func IsMasterPageContext(ctx context.Context) bool {
return ctx.Value(masterPageContextKey{}) != nil
}
// --- HTTP request ---
type requestContextKey struct{}
func WithRequest(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, requestContextKey{}, r)
}
func GetRequest(ctx context.Context) *http.Request {
if r, ok := ctx.Value(requestContextKey{}).(*http.Request); ok {
return r
}
return nil
}
// --- Editor mode ---
type isEditorContextKey struct{}
func WithIsEditor(ctx context.Context, isEditor bool) context.Context {
return context.WithValue(ctx, isEditorContextKey{}, isEditor)
}
func IsEditor(ctx context.Context) bool {
if v, ok := ctx.Value(isEditorContextKey{}).(bool); ok {
return v
}
return false
}
// --- Expected slots ---
type expectedSlotsContextKey struct{}
func WithExpectedSlots(ctx context.Context, slots []string) context.Context {
return context.WithValue(ctx, expectedSlotsContextKey{}, slots)
}
func GetExpectedSlots(ctx context.Context) []string {
if v, ok := ctx.Value(expectedSlotsContextKey{}).([]string); ok {
return v
}
return nil
}
// --- Block ID ---
type blockIDContextKey struct{}
func WithBlockID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, blockIDContextKey{}, id)
}
func GetBlockID(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(blockIDContextKey{}).(uuid.UUID); ok {
return v
}
return uuid.Nil
}
// --- Current page ID ---
type currentPageIDContextKey struct{}
func WithCurrentPageID(ctx context.Context, id uuid.UUID) context.Context {
return context.WithValue(ctx, currentPageIDContextKey{}, id)
}
func GetCurrentPageID(ctx context.Context) uuid.UUID {
if v, ok := ctx.Value(currentPageIDContextKey{}).(uuid.UUID); ok {
return v
}
return uuid.Nil
}
// --- Slot renderer ---
type slotRendererContextKey struct{}
func WithSlotRenderer(ctx context.Context, r SlotRenderer) context.Context {
return context.WithValue(ctx, slotRendererContextKey{}, r)
}
func GetSlotRenderer(ctx context.Context) SlotRenderer {
if r, ok := ctx.Value(slotRendererContextKey{}).(SlotRenderer); ok {
return r
}
return nil
}
// --- Human proof banner ---
type humanProofBannerContextKey struct{}
func WithHumanProofBanner(ctx context.Context, data *HumanProofBannerData) context.Context {
return context.WithValue(ctx, humanProofBannerContextKey{}, data)
}
func GetHumanProofBanner(ctx context.Context) *HumanProofBannerData {
if v, ok := ctx.Value(humanProofBannerContextKey{}).(*HumanProofBannerData); ok {
return v
}
return nil
}
// --- Media resolver ---
// MediaResolver resolves media metadata from storage.
// The CMS implements this with db.Queries.GetMedia.
type MediaResolver interface {
ResolveMedia(ctx context.Context, mediaID uuid.UUID) *MediaValue
}
type mediaResolverContextKey struct{}
func WithMediaResolver(ctx context.Context, resolver MediaResolver) context.Context {
return context.WithValue(ctx, mediaResolverContextKey{}, resolver)
}
func GetMediaResolver(ctx context.Context) MediaResolver {
if r, ok := ctx.Value(mediaResolverContextKey{}).(MediaResolver); ok {
return r
}
return nil
}
// --- Embed resolver ---
type embedResolverContextKey struct{}
func WithEmbedResolver(ctx context.Context, resolver EmbedResolver) context.Context {
return context.WithValue(ctx, embedResolverContextKey{}, resolver)
}
func GetEmbedResolver(ctx context.Context) EmbedResolver {
if r, ok := ctx.Value(embedResolverContextKey{}).(EmbedResolver); ok {
return r
}
return nil
}
// --- RenderSlot ---
// RenderSlot renders children for the current container block.
func RenderSlot(ctx context.Context) string {
blockID := GetBlockID(ctx)
if blockID == uuid.Nil {
return ""
}
renderer := GetSlotRenderer(ctx)
if renderer == nil {
return ""
}
return renderer.RenderContainerSlot(ctx, blockID)
}
// --- BlockContext (pongo2 template data) ---
// BlockContext provides contextual information available to all block templates.
type BlockContext struct {
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
}
type blockContextKey struct{}
func WithBlockContext(ctx context.Context, blockCtx *BlockContext) context.Context {
return context.WithValue(ctx, blockContextKey{}, blockCtx)
}
func GetBlockContext(ctx context.Context) *BlockContext {
if bc, ok := ctx.Value(blockContextKey{}).(*BlockContext); ok {
return bc
}
return nil
}
// ToMap converts the BlockContext to a map for pongo2 template injection.
func (bc *BlockContext) ToMap() map[string]any {
return map[string]any{
"url": bc.URL, "path": bc.Path, "slug": bc.Slug,
"pageId": bc.PageID, "pageTitle": bc.PageTitle,
"templateKey": bc.TemplateKey, "isEditor": bc.IsEditor,
"timestamp": bc.Timestamp, "now": bc.Now,
"isLoggedIn": bc.IsLoggedIn, "userId": bc.UserID,
"userEmail": bc.UserEmail, "userRole": bc.UserRole,
"method": bc.Method, "host": bc.Host, "query": bc.Query,
"referrer": bc.Referrer, "userAgent": bc.UserAgent,
"ip": bc.IP, "cookies": bc.Cookies, "headers": bc.Headers,
"country": bc.Country, "city": bc.City, "timezone": bc.Timezone,
"currentAuthor": bc.CurrentAuthor, "currentPost": bc.CurrentPost,
"currentCategory": bc.CurrentCategory, "site": bc.Site,
"isPublicLoggedIn": bc.IsPublicLoggedIn,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicEmailVerified": bc.PublicEmailVerified,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowData": bc.DetailRowData,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
}
}
// --- Detail row ---
// DetailRowInfo holds the resolved data table row for detail pages.
type DetailRowInfo struct {
TableID string
RowID string
Data map[string]any
}
type detailRowKey struct{}
func WithDetailRow(ctx context.Context, tableID, rowID string, data map[string]any) context.Context {
return context.WithValue(ctx, detailRowKey{}, &DetailRowInfo{TableID: tableID, RowID: rowID, Data: data})
}
func GetDetailRow(ctx context.Context) *DetailRowInfo {
if v, ok := ctx.Value(detailRowKey{}).(*DetailRowInfo); ok {
return v
}
return nil
}

62
blocks/powered.go Normal file
View File

@ -0,0 +1,62 @@
package blocks
import (
"encoding/json"
"strings"
)
// poweredBlockSentinel prefixes a PoweredBlock marker string. It uses NUL
// bytes so it can never collide with real block HTML: a BlockFunc returns a
// plain HTML string today, and this marker is a distinct, out-of-band signal
// that the block is "powered" (template + data, rendered host-side).
const poweredBlockSentinel = "\x00bn:powered\x00"
// PoweredResult is the decoded payload of a PoweredBlock marker: the template
// source and its data map. The wasm guest's RENDER_BLOCK handler decodes it
// and forwards it as abiv1.PoweredBlock so the HOST renders the template with
// pongo2 — after the block-invoke has returned, keeping the guest free.
type PoweredResult struct {
Template string `json:"template"`
Data map[string]any `json:"data"`
}
// PoweredBlock marks a block's return value as "powered": instead of final
// HTML, the block hands back a template string plus a data map, and the host
// renders it (pongo2/ninjatpl) host-side. Return its result directly from a
// BlockFunc:
//
// func MyBlock(ctx context.Context, content map[string]any) string {
// posts := loadPosts(ctx) // build data via capabilities
// return blocks.PoweredBlock(tmpl, map[string]any{"posts": posts})
// }
//
// This is the guest-safe replacement for calling blocks.RenderTemplate inside
// a block: pongo2 never crosses the wasm boundary, so the guest cannot render
// itself — it defers rendering to the host. Because the host renders only
// after RENDER_BLOCK returns, any plugin-declared tag/filter the template
// hits ({% mytag %} / |myfilter) is a fresh RENDER_TAG / APPLY_FILTER invoke,
// never a re-entrant one.
func PoweredBlock(template string, data map[string]any) string {
payload, err := json.Marshal(PoweredResult{Template: template, Data: data})
if err != nil {
// A non-serializable data map is a programming error; fall back to an
// empty-data powered result so the template still renders.
payload, _ = json.Marshal(PoweredResult{Template: template})
}
return poweredBlockSentinel + string(payload)
}
// DecodePoweredBlock reports whether s is a PoweredBlock marker and, if so,
// returns the decoded template + data. The wasm guest uses it to distinguish a
// powered result from plain HTML. A non-marker (ordinary HTML) returns ok=false.
func DecodePoweredBlock(s string) (PoweredResult, bool) {
rest, ok := strings.CutPrefix(s, poweredBlockSentinel)
if !ok {
return PoweredResult{}, false
}
var pr PoweredResult
if err := json.Unmarshal([]byte(rest), &pr); err != nil {
return PoweredResult{}, false
}
return pr, true
}

59
blocks/powered_test.go Normal file
View File

@ -0,0 +1,59 @@
package blocks
import "testing"
func TestPoweredBlockRoundTrip(t *testing.T) {
marker := PoweredBlock("<p>{{ x }}</p>", map[string]any{"x": "y"})
pr, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize its own marker")
}
if pr.Template != "<p>{{ x }}</p>" {
t.Errorf("template = %q", pr.Template)
}
if pr.Data["x"] != "y" {
t.Errorf("data = %v", pr.Data)
}
}
func TestDecodePoweredBlockRejectsPlainHTML(t *testing.T) {
if _, ok := DecodePoweredBlock("<p>hello</p>"); ok {
t.Error("plain HTML must not decode as a powered block")
}
if _, ok := DecodePoweredBlock(""); ok {
t.Error("empty string must not decode as a powered block")
}
}
func TestTagFilterRegistry(t *testing.T) {
ResetRegisteredTagsAndFilters()
t.Cleanup(ResetRegisteredTagsAndFilters)
RegisterTag("b", func(map[string]any, RenderContext) (string, error) { return "b", nil })
RegisterTag("a", func(map[string]any, RenderContext) (string, error) { return "a", nil })
RegisterFilter("f", func(string, map[string]any) (string, error) { return "f", nil })
// Empty name / nil fn are ignored.
RegisterTag("", nil)
RegisterFilter("g", nil)
if names := RegisteredTagNames(); len(names) != 2 || names[0] != "a" || names[1] != "b" {
t.Errorf("tag names = %v, want sorted [a b]", names)
}
if names := RegisteredFilterNames(); len(names) != 1 || names[0] != "f" {
t.Errorf("filter names = %v, want [f]", names)
}
if _, ok := LookupTag("a"); !ok {
t.Error("LookupTag(a) missing")
}
if _, ok := LookupFilter("f"); !ok {
t.Error("LookupFilter(f) missing")
}
if _, ok := LookupTag("missing"); ok {
t.Error("LookupTag(missing) should be absent")
}
ResetRegisteredTagsAndFilters()
if RegisteredTagNames() != nil || RegisteredFilterNames() != nil {
t.Error("reset did not clear registries")
}
}

12
blocks/registry.go Normal file
View File

@ -0,0 +1,12 @@
package blocks
import "io/fs"
// BlockRegistry is the interface that plugins use to register blocks.
type BlockRegistry interface {
Register(meta BlockMeta, fn BlockFunc)
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn BlockFunc)
LoadSchemasFromFS(fsys fs.FS) error
LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error
}

250
blocks/shared/button.go Normal file
View File

@ -0,0 +1,250 @@
package shared
import (
"strconv"
"strings"
)
// ButtonConfig represents a unified button configuration
type ButtonConfig struct {
// Content
Text string
URL string
// Type determines theme defaults
// Built-in: "primary" | "secondary" | "outline" | "ghost" | "link" | "destructive"
// Custom: any user-defined type name from theme.buttons.custom
Type string
// Override Mode
UseThemeDefaults bool // When true, uses CSS variables from theme
// Custom Overrides (only used when UseThemeDefaults = false)
TextColor string // Tailwind class like "text-white" or "text-[#ff5500]"
TextOpacity int // 0-100
BgColor string // Tailwind class like "bg-primary" or "bg-[#ff5500]"
BgOpacity int // 0-100
// Effects (can override theme)
Pill bool // Fully rounded corners
Elevated bool // Shadow
ThreeDee bool // 3D push effect
Size string // "sm" | "md" | "lg"
// Behavior
OpenNewTab bool
}
// Classes returns CSS classes for the button
func (b ButtonConfig) Classes() string {
var classes []string
classes = append(classes, "btn")
// Size class
switch b.Size {
case "sm":
classes = append(classes, "btn-sm")
case "lg":
classes = append(classes, "btn-lg")
default:
classes = append(classes, "btn-md")
}
if b.UseThemeDefaults {
// Use theme CSS variable classes
btnType := b.Type
if btnType == "" {
btnType = "primary"
}
classes = append(classes, "btn-"+btnType)
} else {
// Use custom Tailwind classes
if b.BgColor != "" {
classes = append(classes, b.BgColor)
} else {
// Default based on type variant
switch b.Type {
case "outline":
classes = append(classes, "border-2", "border-primary", "bg-transparent")
case "ghost":
classes = append(classes, "bg-transparent", "hover:bg-accent")
case "link":
classes = append(classes, "bg-transparent", "underline", "underline-offset-4")
case "destructive":
classes = append(classes, "bg-destructive", "hover:bg-destructive/90")
default: // primary, secondary, solid
classes = append(classes, "bg-primary", "hover:bg-primary/90")
}
}
if b.TextColor != "" {
classes = append(classes, b.TextColor)
} else {
switch b.Type {
case "outline":
classes = append(classes, "text-primary")
case "ghost":
classes = append(classes, "text-foreground", "hover:text-accent-foreground")
case "link":
classes = append(classes, "text-primary")
case "destructive":
classes = append(classes, "text-destructive-foreground")
default:
classes = append(classes, "text-primary-foreground")
}
}
}
// Effects that can override theme
if b.Pill {
classes = append(classes, "rounded-full")
}
if b.Elevated && b.Type != "ghost" && b.Type != "link" {
classes = append(classes, "shadow-lg", "hover:shadow-xl")
}
if b.ThreeDee && (b.Type == "" || b.Type == "primary" || b.Type == "solid") {
classes = append(classes, "border-b-4", "border-primary/70", "active:border-b-2", "active:mt-0.5")
}
return strings.Join(classes, " ")
}
// InlineStyle returns inline CSS styles for opacity if needed
func (b ButtonConfig) InlineStyle() string {
var styles []string
if !b.UseThemeDefaults {
if b.TextOpacity > 0 && b.TextOpacity < 100 {
opacity := float64(b.TextOpacity) / 100.0
styles = append(styles, "color-opacity: "+strconv.FormatFloat(opacity, 'f', 2, 64))
}
if b.BgOpacity > 0 && b.BgOpacity < 100 {
opacity := float64(b.BgOpacity) / 100.0
styles = append(styles, "--tw-bg-opacity: "+strconv.FormatFloat(opacity, 'f', 2, 64))
}
}
if len(styles) == 0 {
return ""
}
return strings.Join(styles, "; ")
}
// ParseButton extracts ButtonConfig from JSON content
func ParseButton(value any, defaultType string) ButtonConfig {
btn := ButtonConfig{
Type: defaultType,
UseThemeDefaults: true, // Default to theme styling
TextOpacity: 100,
BgOpacity: 100,
Size: "md",
}
if value == nil {
return btn
}
obj, ok := value.(map[string]any)
if !ok {
return btn
}
// Content
if text, ok := obj["text"].(string); ok {
btn.Text = text
}
if url, ok := obj["url"].(string); ok {
btn.URL = url
}
// Type - check explicit type field first
typeExplicitlySet := false
if t, ok := obj["type"].(string); ok && t != "" {
btn.Type = t
typeExplicitlySet = true
}
// Use theme defaults toggle - explicit setting takes precedence
if useTheme, ok := obj["useThemeDefaults"].(bool); ok {
btn.UseThemeDefaults = useTheme
} else {
// Legacy: if useThemeDefaults not set but custom colors exist, assume not using theme defaults
if textColor, ok := obj["textColor"].(string); ok && textColor != "" {
btn.UseThemeDefaults = false
} else if bgColor, ok := obj["bgColor"].(string); ok && bgColor != "" {
btn.UseThemeDefaults = false
}
}
// Custom overrides
if textColor, ok := obj["textColor"].(string); ok {
btn.TextColor = textColor
}
if textOpacity, ok := obj["textOpacity"].(float64); ok {
btn.TextOpacity = int(textOpacity)
}
if bgColor, ok := obj["bgColor"].(string); ok {
btn.BgColor = bgColor
}
if bgOpacity, ok := obj["bgOpacity"].(float64); ok {
btn.BgOpacity = int(bgOpacity)
}
// Effects
if pill, ok := obj["pill"].(bool); ok {
btn.Pill = pill
}
if elevated, ok := obj["elevated"].(bool); ok {
btn.Elevated = elevated
}
if threeDee, ok := obj["threeDee"].(bool); ok {
btn.ThreeDee = threeDee
}
if size, ok := obj["size"].(string); ok && size != "" {
btn.Size = size
}
// Behavior
if openNewTab, ok := obj["openNewTab"].(bool); ok {
btn.OpenNewTab = openNewTab
}
// Legacy support: "style" field from old format
if style, ok := obj["style"].(string); ok && style != "" && btn.Type == defaultType {
// Map legacy styles to types
switch style {
case "secondary":
btn.Type = "secondary"
case "outline":
btn.Type = "outline"
case "ghost":
btn.Type = "ghost"
case "link":
btn.Type = "link"
case "destructive":
btn.Type = "destructive"
default:
btn.Type = "primary"
}
}
// Legacy: "variant" field - only use if type wasn't explicitly set
if !typeExplicitlySet {
if variant, ok := obj["variant"].(string); ok && variant != "" {
if variant == "solid" {
btn.Type = "primary"
} else {
btn.Type = variant
}
}
}
return btn
}
// HasContent returns true if the button has text and URL
func (b ButtonConfig) HasContent() bool {
return b.Text != "" && b.URL != ""
}

View File

@ -0,0 +1,54 @@
package shared
// RenderButton renders a button or link based on ButtonConfig
templ RenderButton(btn ButtonConfig) {
if btn.URL != "" {
if btn.InlineStyle() != "" {
<a
href={ templ.SafeURL(btn.URL) }
class={ btn.Classes() }
if btn.OpenNewTab {
target="_blank"
rel="noopener noreferrer"
}
style={ btn.InlineStyle() }
>
{ btn.Text }
</a>
} else {
<a
href={ templ.SafeURL(btn.URL) }
class={ btn.Classes() }
if btn.OpenNewTab {
target="_blank"
rel="noopener noreferrer"
}
>
{ btn.Text }
</a>
}
} else {
if btn.InlineStyle() != "" {
<button type="button" class={ btn.Classes() } style={ btn.InlineStyle() }>
{ btn.Text }
</button>
} else {
<button type="button" class={ btn.Classes() }>
{ btn.Text }
</button>
}
}
}
// RenderSubmitButton renders a submit button
templ RenderSubmitButton(btn ButtonConfig) {
if btn.InlineStyle() != "" {
<button type="submit" class={ btn.Classes() } style={ btn.InlineStyle() }>
{ btn.Text }
</button>
} else {
<button type="submit" class={ btn.Classes() }>
{ btn.Text }
</button>
}
}

View File

@ -0,0 +1,370 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package shared
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// RenderButton renders a button or link based on ButtonConfig
func RenderButton(btn ButtonConfig) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if btn.URL != "" {
if btn.InlineStyle() != "" {
var templ_7745c5c3_Var2 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 8, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if btn.OpenNewTab {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " target=\"_blank\" rel=\"noopener noreferrer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 14, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 16, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var7 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(btn.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 20, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if btn.OpenNewTab {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " target=\"_blank\" rel=\"noopener noreferrer\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 27, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
if btn.InlineStyle() != "" {
var templ_7745c5c3_Var11 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 32, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 33, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var15 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 37, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
return nil
})
}
// RenderSubmitButton renders a submit button
func RenderSubmitButton(btn ButtonConfig) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil {
templ_7745c5c3_Var18 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if btn.InlineStyle() != "" {
var templ_7745c5c3_Var19 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"submit\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(btn.InlineStyle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 46, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 47, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var23 = []any{btn.Classes()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button type=\"submit\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var23).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(btn.Text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `blocks/shared/button.templ`, Line: 51, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

99
blocks/tagfilter.go Normal file
View File

@ -0,0 +1,99 @@
package blocks
import (
"context"
"sort"
)
// RenderContext is the render-time context handed to a plugin template tag.
// It is the standard context.Context that carries the active render values,
// so a tag fn reads page/post/author/request state with the same accessors
// blocks use everywhere else (blocks.GetCurrentPage(rctx),
// blocks.GetRequest(rctx), blocks.GetTemplateKey(rctx), …).
type RenderContext = context.Context
// TagFunc renders a plugin-provided template tag. args holds the tag's parsed
// arguments; rctx is the active render context. It returns the tag's HTML.
type TagFunc func(args map[string]any, rctx RenderContext) (string, error)
// FilterFunc applies a plugin-provided template filter to input. args holds
// the filter's arguments (e.g. {{ x|myfilter:"arg" }}). It returns the
// filtered output.
type FilterFunc func(input string, args map[string]any) (string, error)
// Tag/filter registries are package-level singletons, mirroring
// RegisterTemplateRenderer: in the wasm guest exactly one plugin owns the
// module, so a global registry is the natural home for its RegisterTag /
// RegisterFilter calls. The guest snapshots names into the DESCRIBE manifest
// (declared_tags / declared_filters) and dispatches HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER through LookupTag / LookupFilter.
var (
registeredTags = map[string]TagFunc{}
registeredFilters = map[string]FilterFunc{}
)
// RegisterTag registers a custom template tag under name. Call it from your
// plugin's Register func (alongside block registration) so DESCRIBE captures
// it into manifest.declared_tags and the host wires a pongo2 tag that calls
// back via HOOK_RENDER_TAG. A later registration of the same name wins.
func RegisterTag(name string, fn TagFunc) {
if name == "" || fn == nil {
return
}
registeredTags[name] = fn
}
// RegisterFilter registers a custom template filter under name. Call it from
// your plugin's Register func so DESCRIBE captures it into
// manifest.declared_filters and the host wires a pongo2 filter that calls back
// via HOOK_APPLY_FILTER. A later registration of the same name wins.
func RegisterFilter(name string, fn FilterFunc) {
if name == "" || fn == nil {
return
}
registeredFilters[name] = fn
}
// RegisteredTagNames returns the registered tag names, sorted (deterministic
// manifests). Read by the wasm guest's DESCRIBE handler.
func RegisteredTagNames() []string {
return sortedRegistryKeys(registeredTags)
}
// RegisteredFilterNames returns the registered filter names, sorted.
func RegisteredFilterNames() []string {
return sortedRegistryKeys(registeredFilters)
}
// LookupTag resolves a registered tag fn for HOOK_RENDER_TAG dispatch.
func LookupTag(name string) (TagFunc, bool) {
fn, ok := registeredTags[name]
return fn, ok
}
// LookupFilter resolves a registered filter fn for HOOK_APPLY_FILTER dispatch.
func LookupFilter(name string) (FilterFunc, bool) {
fn, ok := registeredFilters[name]
return fn, ok
}
// ResetRegisteredTagsAndFilters clears the tag/filter registries. The wasm
// guest calls it before running a registration's Register pass so each
// installed plugin starts from a clean slate (and so publish-time DESCRIBE is
// deterministic). Also used by tests.
func ResetRegisteredTagsAndFilters() {
registeredTags = map[string]TagFunc{}
registeredFilters = map[string]FilterFunc{}
}
func sortedRegistryKeys[V any](m map[string]V) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

1
blocks/tags/doc.go Normal file
View File

@ -0,0 +1 @@
package tags

74
blocks/template.go Normal file
View File

@ -0,0 +1,74 @@
package blocks
import (
"fmt"
"html"
)
// MediaValue represents a rich media object for template substitution.
// Implements fmt.Stringer to render a full <img> tag when used directly.
type MediaValue struct {
Src string // Full URL path (e.g., "/media/uuid/webp.webp")
Alt string // Alt text from media library
Filename string // Original filename
Width int // Image width in pixels
Height int // Image height in pixels
}
// String renders the default <img> tag representation.
func (m MediaValue) String() string {
attrs := fmt.Sprintf(`src="%s" alt="%s" class="w-full h-auto"`,
html.EscapeString(m.Src),
html.EscapeString(m.Alt))
if m.Width > 0 && m.Height > 0 {
attrs += fmt.Sprintf(` width="%d" height="%d"`, m.Width, m.Height)
}
return fmt.Sprintf(`<img %s>`, attrs)
}
// RenderWithClass renders the <img> tag with custom CSS classes.
func (m MediaValue) RenderWithClass(class string) string {
attrs := fmt.Sprintf(`src="%s" alt="%s" class="%s"`,
html.EscapeString(m.Src),
html.EscapeString(m.Alt),
html.EscapeString(class))
if m.Width > 0 && m.Height > 0 {
attrs += fmt.Sprintf(` width="%d" height="%d"`, m.Width, m.Height)
}
return fmt.Sprintf(`<img %s>`, attrs)
}
// TemplateRenderer is the interface for rendering pongo2/Jinja2-style templates.
// The CMS registers an implementation at startup.
type TemplateRenderer interface {
RenderTemplate(tmpl string, data map[string]any) (string, error)
ProcessIcons(html string) string
}
// templateRenderer holds the registered template renderer.
var templateRenderer TemplateRenderer
// RegisterTemplateRenderer sets the global template renderer.
// Called by the CMS at startup to provide pongo2 rendering.
func RegisterTemplateRenderer(r TemplateRenderer) {
templateRenderer = r
}
// RenderTemplate renders a pongo2/Jinja2-style template string with the given data.
// Returns the rendered HTML or an error. Falls back to a no-op if no renderer is registered.
func RenderTemplate(tmpl string, data map[string]any) (string, error) {
if templateRenderer == nil {
// No renderer registered — return template as-is (no variable substitution)
return tmpl, nil
}
return templateRenderer.RenderTemplate(tmpl, data)
}
// ProcessIcons processes ::pack:name:: icon syntax in HTML.
// Returns the HTML with icons replaced, or the original if no renderer is registered.
func ProcessIcons(htmlStr string) string {
if templateRenderer == nil {
return htmlStr
}
return templateRenderer.ProcessIcons(htmlStr)
}

106
blocks/types.go Normal file
View File

@ -0,0 +1,106 @@
package blocks
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// BlockFunc renders a block given its content data.
type BlockFunc func(ctx context.Context, content map[string]any) string
// BlockCategory represents categories for organizing blocks in the palette.
type BlockCategory string
const (
CategoryContent BlockCategory = "content"
CategoryLayout BlockCategory = "layout"
CategoryNavigation BlockCategory = "navigation"
CategoryBlog BlockCategory = "blog"
CategoryTheme BlockCategory = "theme"
)
// BlockMeta provides metadata about a block type for admin UI and validation.
type BlockMeta struct {
Key string
Title string
Description string
Category BlockCategory
HasInternalSlot bool
Source string
Hidden bool
EditorJS string
}
// BlockNode represents a block with its content and children for rendering.
type BlockNode struct {
ID uuid.UUID
BlockKey string
Title string
Content map[string]any
HtmlContent string
Children []*BlockNode
SortOrder int
}
// SlotRenderer is the interface for rendering container slots.
type SlotRenderer interface {
RenderContainerSlot(ctx context.Context, containerID uuid.UUID) string
}
// HumanProofBannerData holds data for the public human proof banner.
type HumanProofBannerData struct {
ActiveTimeMinutes int
KeystrokeCount int
SessionCount int
PostSlug string
}
// RenderHumanProofBanner renders the public-facing banner HTML.
func RenderHumanProofBanner(hp *HumanProofBannerData) string {
return fmt.Sprintf(`<div id="hp-banner-%[4]s" data-human-proof-banner class="rounded-lg overflow-hidden my-6">`+
`<div class="flex items-center justify-between py-3 px-5 bg-muted border border-border rounded-lg">`+
`<div class="flex items-center gap-3">`+
`<div class="w-8 h-8 bg-primary text-primary-foreground rounded-md flex items-center justify-center text-xs font-bold">HP</div>`+
`<div>`+
`<div class="font-semibold text-sm text-foreground">Human Proof</div>`+
`<div class="text-xs text-muted-foreground">%[1]d min active &middot; %[2]s keystrokes &middot; %[3]d sessions</div>`+
`</div></div>`+
`<button class="px-4 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium cursor-pointer hover:bg-primary/90 border-0" data-action="watch">Watch this post being written</button>`+
`</div></div>`+
`<script src="/assets/human-proof-player.js" defer></script>`+
`<script>document.addEventListener("DOMContentLoaded",function(){if(window.HumanProof){window.HumanProof.init({postSlug:%[4]q,bannerId:"hp-banner-%[4]s",contentId:"hp-content-%[4]s"})}});</script>`,
hp.ActiveTimeMinutes,
formatThousands(hp.KeystrokeCount),
hp.SessionCount,
hp.PostSlug,
)
}
func formatThousands(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
s := fmt.Sprintf("%d", n)
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
return string(result)
}
// ResolveMediaPath converts a media: prefixed path to a /media/ URL path.
func ResolveMediaPath(path string) string {
if after, ok := strings.CutPrefix(path, "media:"); ok {
return "/media/" + after
}
if _, err := uuid.Parse(path); err == nil {
return "/media/" + path
}
return path
}

86
content/author.go Normal file
View File

@ -0,0 +1,86 @@
package content
import (
"context"
"github.com/google/uuid"
)
// Author provides imperative page/post authoring for plugins (WO-WZ-019).
// The CMS implements this interface and wires it into CoreServices.
//
// These are the runtime counterparts of the idempotent seed-time operations
// on plugin.Provisioner: CreatePage is create-or-return-existing, the rest
// mutate an existing page/post. Blog posts live in the CMS's dedicated
// blog_posts table (not the pages table).
type Author interface {
CreatePage(ctx context.Context, params CreatePageParams) (CreatePageResult, error)
SetPageBlocks(ctx context.Context, pageID uuid.UUID, blocks []PageBlock) error
PublishPage(ctx context.Context, pageID uuid.UUID) error
SetPageSEO(ctx context.Context, pageID uuid.UUID, seo PageSEO) error
UpsertPost(ctx context.Context, params UpsertPostParams) (UpsertPostResult, error)
}
// CreatePageParams describes a page to create.
type CreatePageParams struct {
Slug string
ParentSlug string // "" = root
Title string
TemplateKey string
MasterPageKey string // "" = none
}
// CreatePageResult reports the created (or pre-existing) page.
type CreatePageResult struct {
PageID uuid.UUID
// Created is false when a page with the slug already existed; the
// existing ID is returned and nothing is modified.
Created bool
}
// PageBlock is one block instance placed on a page.
type PageBlock struct {
BlockKey string
Title string
Content map[string]any
HTMLContent *string
Slot string
SortOrder int32
}
// PageSEO carries the SEO fields settable on a page. Nil pointers leave the
// existing value untouched.
type PageSEO struct {
MetaTitle *string
MetaDescription *string
OGTitle *string
OGDescription *string
OGImage *string
FocusKeyphrase *string
CanonicalURL *string
RobotsDirective *string
TwitterTitle *string
TwitterDescription *string
TwitterImage *string
}
// UpsertPostParams creates or updates a blog post by slug.
type UpsertPostParams struct {
Slug string
Title string
Document map[string]any // BlockNote document
Excerpt *string
// AuthorProfileID / FeaturedImageID reference existing rows (e.g. a
// media.deposit result for the image).
AuthorProfileID *uuid.UUID
FeaturedImageID *uuid.UUID
// Publish publishes the post immediately after the upsert.
Publish bool
IsFeatured bool
}
// UpsertPostResult reports the upserted post.
type UpsertPostResult struct {
PostID uuid.UUID
Created bool
}

68
content/content.go Normal file
View File

@ -0,0 +1,68 @@
package content
import (
"context"
"time"
"github.com/google/uuid"
)
// AuthorProfile is a simplified author representation for plugins.
type AuthorProfile struct {
ID uuid.UUID
Name string
Slug string
Bio string
AvatarURL string
Website string
SocialLinks map[string]string
}
// PageInfo is a simplified page representation for plugins.
type PageInfo struct {
ID uuid.UUID
Slug string
Title string
}
// PostInfo is a simplified post representation for plugins. It carries enough
// for both a blog index (title/slug/excerpt/author/date/featured image) and a
// post detail view (Body — the rendered HTML). Body is populated by GetPost and
// by ListPosts when ListPostsParams.IncludeBody is set; it is empty otherwise.
type PostInfo struct {
ID uuid.UUID
Slug string
Title string
Excerpt string
Body string // rendered HTML; populated by GetPost / ListPosts(IncludeBody)
FeaturedImageURL string
AuthorID uuid.UUID
AuthorName string
AuthorSlug string
PublishedAt time.Time
}
// ListPostsParams filters and shapes a ListPosts query. The zero value lists
// published posts from the start with no category filter, excerpts included and
// bodies omitted (the cheap blog-index shape).
type ListPostsParams struct {
Limit int // 0 = host default
Offset int // pagination offset
Category string // optional category slug filter ("" = all)
PublishedOnly bool // reserved: plugins only ever see published posts
IncludeBody bool // render each post's body to HTML (expensive)
IncludeExcerpt bool // include the post excerpt
}
// Content provides content access for plugins.
// The CMS implements this interface and wires it into CoreServices.
type Content interface {
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
GetPage(ctx context.Context, slug string) (*PageInfo, error)
GetPost(ctx context.Context, slug string) (*PostInfo, error)
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
Slugify(text string) string
BlockNoteToHTML(ctx context.Context, doc map[string]any) string
GenerateExcerpt(html string, maxLen int) string
StripHTML(s string) string
}

7
crypto/crypto.go Normal file
View File

@ -0,0 +1,7 @@
package crypto
// Crypto provides encryption/decryption for plugins.
type Crypto interface {
EncryptSecret(plaintext string) (string, error)
DecryptSecret(ciphertext string) (string, error)
}

View File

@ -0,0 +1,21 @@
package datasources
import (
"context"
"github.com/google/uuid"
)
// Datasources provides bucket/datasource access for plugins.
// The CMS implements this interface and wires it into CoreServices.
type Datasources interface {
ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*Result, error)
ResolveBucketByKey(ctx context.Context, bucketKey string) (*Result, error)
}
// Result is the output from resolving a bucket.
type Result struct {
Items []any `json:"items"`
Total int `json:"total"`
Meta map[string]any `json:"meta,omitempty"`
}

45
gating/gating.go Normal file
View File

@ -0,0 +1,45 @@
package gating
import (
"context"
"github.com/google/uuid"
)
// AccessRule defines content access requirements.
type AccessRule struct {
MinTierLevel int
OverrideTierID string
TeaserMode string // "hard", "soft", "none"
TeaserPercent int
}
// AccessResult is the result of evaluating access for a user.
type AccessResult struct {
HasAccess bool
TeaserMode string
TeaserPercent int
RequiredLevel int
}
// EvaluateAccess checks whether a user's tier level grants access based on the rule.
func EvaluateAccess(userTierLevel int, rule *AccessRule) AccessResult {
if rule == nil {
return AccessResult{HasAccess: true}
}
if userTierLevel >= rule.MinTierLevel {
return AccessResult{HasAccess: true}
}
return AccessResult{
HasAccess: false,
TeaserMode: rule.TeaserMode,
TeaserPercent: rule.TeaserPercent,
RequiredLevel: rule.MinTierLevel,
}
}
// Gating provides gating access for plugins.
type Gating interface {
GetSubscriberTierLevel(ctx context.Context, userID uuid.UUID) (int, error)
EvaluateAccess(userTierLevel int, rule *AccessRule) AccessResult
}

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module git.dev.alexdunmow.com/block/pluginsdk
go 1.26.4
require (
connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
github.com/BurntSushi/toml v1.6.0
github.com/a-h/templ v0.3.1020
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/tetratelabs/wazero v1.12.0
golang.org/x/mod v0.37.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.33.0 // indirect
)

46
go.sum Normal file
View File

@ -0,0 +1,46 @@
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2 h1:KQS90HNW35PSzvwDZ3Z9OOwX3OSjX0x62w0AtOYps8s=
git.dev.alexdunmow.com/block/ninjatpl v1.0.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

34
menus/menus.go Normal file
View File

@ -0,0 +1,34 @@
package menus
import (
"context"
"github.com/google/uuid"
)
// Menus provides menu and navigation access for plugins.
type Menus interface {
GetMenuByName(ctx context.Context, name string) (*Menu, error)
GetMenuItems(ctx context.Context, menuID uuid.UUID) ([]MenuItem, error)
}
// Menu represents a named navigation menu.
type Menu struct {
ID uuid.UUID
Name string
}
// MenuItem represents a single entry in a navigation menu.
type MenuItem struct {
ID uuid.UUID
MenuID uuid.UUID
Label string
URL string
PageSlug string
ParentID *uuid.UUID
SortOrder int32
OpenInNewTab bool
CssClass string
ItemType string
Icon string
}

43
plugin/block_registry.go Normal file
View File

@ -0,0 +1,43 @@
package plugin
import (
"io/fs"
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
)
// PluginBlockRegistry wraps a BlockRegistry to auto-prefix block keys with the plugin name.
type PluginBlockRegistry struct {
inner blocks.BlockRegistry
prefix string
}
// NewPluginBlockRegistry creates a block registry that auto-prefixes keys.
func NewPluginBlockRegistry(inner blocks.BlockRegistry, pluginName string) *PluginBlockRegistry {
return &PluginBlockRegistry{inner: inner, prefix: pluginName}
}
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
if !strings.Contains(meta.Key, ":") {
meta.Key = r.prefix + ":" + meta.Key
}
meta.Source = r.prefix
r.inner.Register(meta, fn)
}
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, r.prefix, fn)
}
func (r *PluginBlockRegistry) RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn blocks.BlockFunc) {
r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, source, fn)
}
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
return r.inner.LoadSchemasFromFSWithPrefix(fsys, r.prefix)
}
func (r *PluginBlockRegistry) LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error {
return r.inner.LoadSchemasFromFSWithPrefix(fsys, prefix)
}

View File

@ -0,0 +1,70 @@
package plugin
import (
"io/fs"
"testing"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
)
type recordingBlockRegistry struct {
registeredKey string
registeredSource string
overrideSource string
schemaPrefix string
}
func (r *recordingBlockRegistry) Register(meta blocks.BlockMeta, _ blocks.BlockFunc) {
r.registeredKey = meta.Key
r.registeredSource = meta.Source
}
func (r *recordingBlockRegistry) RegisterTemplateOverride(_, _ string, _ blocks.BlockFunc) {}
func (r *recordingBlockRegistry) RegisterTemplateOverrideWithSource(_, _, source string, _ blocks.BlockFunc) {
r.overrideSource = source
}
func (r *recordingBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
return r.LoadSchemasFromFSWithPrefix(fsys, "")
}
func (r *recordingBlockRegistry) LoadSchemasFromFSWithPrefix(_ fs.FS, prefix string) error {
r.schemaPrefix = prefix
return nil
}
func TestPluginBlockRegistryPrefixesOnlyUnqualifiedKeys(t *testing.T) {
inner := &recordingBlockRegistry{}
registry := NewPluginBlockRegistry(inner, "course")
registry.Register(blocks.BlockMeta{Key: "lesson"}, nil)
if inner.registeredKey != "course:lesson" {
t.Fatalf("Register() key = %q, want course:lesson", inner.registeredKey)
}
if inner.registeredSource != "course" {
t.Fatalf("Register() source = %q, want course", inner.registeredSource)
}
registry.Register(blocks.BlockMeta{Key: "course:lesson"}, nil)
if inner.registeredKey != "course:lesson" {
t.Fatalf("Register() double-prefixed qualified key: %q", inner.registeredKey)
}
}
func TestPluginBlockRegistryTracksOverrideAndSchemaSource(t *testing.T) {
inner := &recordingBlockRegistry{}
registry := NewPluginBlockRegistry(inner, "course")
registry.RegisterTemplateOverride("shared-theme", "lesson", nil)
if inner.overrideSource != "course" {
t.Fatalf("override source = %q, want course", inner.overrideSource)
}
if err := registry.LoadSchemasFromFS(nil); err != nil {
t.Fatalf("LoadSchemasFromFS() error = %v", err)
}
if inner.schemaPrefix != "course" {
t.Fatalf("schema prefix = %q, want course", inner.schemaPrefix)
}
}

42
plugin/bridge.go Normal file
View File

@ -0,0 +1,42 @@
package plugin
import "context"
// PluginBridge allows plugins to share services with each other.
// Plugins register named services during startup; other plugins look them up at runtime.
//
// In-process (bundled) plugins can share typed Go values via
// RegisterService/GetService. Across the wasm sandbox a typed value cannot
// cross, so cross-plugin calls go through Invoke instead: the provider's
// service implements BridgeInvokable and the consumer calls Invoke with an
// opaque payload (JSON by convention). GetService still reports availability
// only (nil) for wasm consumers.
type PluginBridge interface {
RegisterService(pluginName, serviceName string, service any)
GetService(pluginName, serviceName string) any
// Invoke calls a method on another plugin's registered bridge service.
// The provider answers via its BridgeInvokable implementation (bundled)
// or the BRIDGE_CALL hook (wasm).
Invoke(ctx context.Context, pluginName, serviceName, method string, payload []byte) ([]byte, error)
}
// BridgeInvokable is implemented by bridge service values that support
// method-by-name invocation with opaque payloads, making them callable from
// other plugins across the wasm sandbox boundary.
type BridgeInvokable interface {
InvokeBridge(ctx context.Context, method string, payload []byte) ([]byte, error)
}
// GetServiceAs retrieves a typed service from the bridge.
func GetServiceAs[T any](bridge PluginBridge, pluginName, serviceName string) (T, bool) {
var zero T
if bridge == nil {
return zero, false
}
svc := bridge.GetService(pluginName, serviceName)
if svc == nil {
return zero, false
}
typed, ok := svc.(T)
return typed, ok
}

13
plugin/community.go Normal file
View File

@ -0,0 +1,13 @@
package plugin
import (
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
)
// CoreServiceBindings provides pre-built CMS service bindings that plugins
// can include in their ServiceRegistration with custom RBAC role mappings.
// The CMS constructs the actual service handlers — plugins just specify which
// services to mount and what roles apply.
type CoreServiceBindings interface {
Bind(serviceName string, methodRoles map[string]rbac.Role) (ConnectServiceBinding, error)
}

44
plugin/css_manifest.go Normal file
View File

@ -0,0 +1,44 @@
package plugin
import "maps"
// CSSManifest declares a plugin's CSS dependencies and customizations.
type CSSManifest struct {
NPMPackages map[string]string `json:"npm_packages,omitempty"`
CSSDirectives []string `json:"css_directives,omitempty"`
InputCSSAppend string `json:"input_css_append,omitempty"`
}
// MergedCSSManifest aggregates CSS manifests from all plugins.
type MergedCSSManifest struct {
NPMPackages map[string]string `json:"npm_packages"`
CSSDirectives []string `json:"css_directives"`
InputCSSAppend string `json:"input_css_append"`
Sources []string `json:"sources"`
}
// MergeCSSManifests combines multiple plugin CSS manifests into one.
func MergeCSSManifests(manifests map[string]*CSSManifest) *MergedCSSManifest {
merged := &MergedCSSManifest{
NPMPackages: make(map[string]string),
}
for name, m := range manifests {
if m == nil {
continue
}
merged.Sources = append(merged.Sources, name)
maps.Copy(merged.NPMPackages, m.NPMPackages)
merged.CSSDirectives = append(merged.CSSDirectives, m.CSSDirectives...)
if m.InputCSSAppend != "" {
if merged.InputCSSAppend != "" {
merged.InputCSSAppend += "\n"
}
merged.InputCSSAppend += "/* === Plugin: " + name + " === */\n"
merged.InputCSSAppend += m.InputCSSAppend
}
}
return merged
}

147
plugin/deps.go Normal file
View File

@ -0,0 +1,147 @@
package plugin
import (
"context"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/block/pluginsdk/ai"
"git.dev.alexdunmow.com/block/pluginsdk/auth"
"git.dev.alexdunmow.com/block/pluginsdk/content"
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
"git.dev.alexdunmow.com/block/pluginsdk/gating"
"git.dev.alexdunmow.com/block/pluginsdk/menus"
"git.dev.alexdunmow.com/block/pluginsdk/settings"
"git.dev.alexdunmow.com/block/pluginsdk/subscriptions"
"github.com/google/uuid"
)
// CoreServices provides CMS capabilities to plugins.
type CoreServices struct {
// Capability interfaces — typed access to CMS functionality
Content content.Content
ContentAuthor content.Author
Settings settings.Settings
Gating gating.Gating
Crypto crypto.Crypto
Menus menus.Menus
Datasources datasources.Datasources
PublicUsers auth.PublicUsers
Subscriptions subscriptions.Subscriptions
// Database — for plugin's own sqlc queries
Pool Pool
// RPC interceptors — core-provided Connect handler options
Interceptors connect.Option
// Site configuration
MediaPath string
AppURL string
// Media library — deposit images through the full upload pipeline
Media Media
// AI
ToolRegistry ai.ToolRegistry
AITextCall func(ctx context.Context, taskKey, systemPrompt, userMessage string) (string, error)
// Email
EmailSender EmailSender
// Plugin interop
Bridge PluginBridge
// Core RPC services — pre-built bindings for CMS-provided services
CoreServiceBindings CoreServiceBindings
ReviewSubmitter ReviewSubmitter
BadgeRefresher BadgeRefresher
SettingsUpdater settings.Updater
// Extension points — typed as narrow interfaces where possible
JobRunner JobRunner
EmbeddingService EmbeddingService
RAGService RAGService
// Provisioner — idempotent ensure/seed operations. In the wasm world this
// is a LOAD-TIME capability: call it from the Load hook, not from
// Register/RegisterWithProvisioner (DESCRIBE stubs every host function to
// fail, so register-time provisioning cannot cross the ABI).
Provisioner Provisioner
}
// MediaDeposit describes an image a plugin deposits into the CMS media library.
// The bytes flow through the same pipeline as an admin upload (optimize, WebP,
// thumbnails, LQIP, dimensions); responsive variants are generated on demand.
type MediaDeposit struct {
// ID is the media row's primary key. REQUIRED for Provisioner.EnsureMedia —
// it is the deterministic, template-referable key a seeded {% img %} tag
// resolves by. Optional for Media.Deposit, where a zero value is generated.
ID uuid.UUID
// Filename is the original filename, e.g. "hero.jpg".
Filename string
// Data is the raw image bytes, typically from go:embed.
Data []byte
// AltText is optional accessibility text.
AltText string
// Folder optionally groups the media under a named library folder,
// created if absent.
Folder string
// Source is an optional provenance label, e.g. the plugin name.
Source string
}
// MediaResult reports the outcome of a deposit.
type MediaResult struct {
// ID is the media row's primary key (the supplied ID, or a generated one).
ID uuid.UUID
// Ref is a ready-to-use reference ("media:<uuid>") for a block src or a
// {% img %} tag.
Ref string
// Created is false when an existing row with the supplied ID was reused.
Created bool
}
// Media lets a plugin deposit images into the CMS media library at runtime.
// Seed-time deposits use Provisioner.EnsureMedia instead — it is idempotent
// (skip if the ID exists; on changed bytes it warns rather than overwriting).
type Media interface {
Deposit(ctx context.Context, deposit MediaDeposit) (MediaResult, error)
}
// JobRunner submits background jobs for async processing.
type JobRunner interface {
Submit(ctx context.Context, jobType string, config []byte) error
}
// EmbeddingService generates and manages text embeddings.
type EmbeddingService interface {
GenerateEmbedding(ctx context.Context, text string) ([]float32, error)
EmbedContent(ctx context.Context, sourceType string, sourceID uuid.UUID, text string) (bool, error)
IsAvailable() bool
}
// ContentFetcher retrieves the title and full text for a content item so the
// RAG service can re-index it after changes. Plugins register one per content type.
type ContentFetcher func(ctx context.Context, contentID uuid.UUID) (title string, text string, err error)
// RAGService provides retrieval-augmented generation for AI agents.
type RAGService interface {
Query(ctx context.Context, query string, limit int) ([]RAGResult, error)
RegisterContentFetcher(contentType string, fetcher ContentFetcher)
OnContentChanged(ctx context.Context, contentType string, contentID uuid.UUID)
}
// RAGResult is a single result from a RAG query.
type RAGResult struct {
Content string
Score float64
Metadata map[string]string
}
// BadgeRefresher recomputes badges for a data table row.
// The CMS handles loading the table schema, aggregating ratings,
// evaluating badge rules, and persisting the updated badge list.
type BadgeRefresher interface {
RefreshBadges(ctx context.Context, tableID, rowID uuid.UUID) error
}

150
plugin/mod.go Normal file
View File

@ -0,0 +1,150 @@
package plugin
import (
"fmt"
"regexp"
"strings"
tomlpkg "github.com/BurntSushi/toml"
)
type ModFile struct {
Plugin ModPlugin `toml:"plugin"`
Compatibility *ModCompat `toml:"compatibility"`
Requires []ModRequirement `toml:"requires"`
}
type ModPlugin struct {
// Name is the lowercase identifier used for the plugin slug in URLs and DB
// lookups. The CLI normalises this on write; the registry normalises on
// create. Use DisplayName for human-readable presentation.
Name string `toml:"name"`
// DisplayName is the human-readable form (any case). Optional; if empty
// the registry falls back to the input name with its original case.
DisplayName string `toml:"display_name,omitempty"`
// Description is the short summary surfaced in the registry. Optional.
Description string `toml:"description,omitempty"`
// Scope is the plugin owner namespace as it appears in plugin.mod. It may
// include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") —
// both forms are accepted. Consumers comparing scopes should trim the "@"
// before comparing; use ModFile.Coords() for a normalised display string.
Scope string `toml:"scope"`
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
Categories []string `toml:"categories,omitempty"`
Tags []string `toml:"tags,omitempty"`
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
// standalone-plugin loader honours this best-effort by auto-installing any
// missing packs from the bundled registry; slugs outside that whitelist are
// logged and skipped (admins install them manually). Empty / omitted means
// the plugin has no icon-pack dependencies.
RequiredIconPacks []string `toml:"required_icon_packs,omitempty"`
// Private marks the plugin as account-scoped. When true, Coords() returns
// the canonical "@private/<name>@<version>" form regardless of the Scope
// field, and the publish flow attributes the plugin to the publisher's
// active account rather than to a public scope.
Private bool `toml:"private,omitempty"`
// DataDir requests a persistent per-plugin /data preopen at load time. It
// is a first-class field (not an arbitrary key) precisely so the CLI's
// mod round-trip cannot silently drop it: writeMod reconstructs plugin.mod
// from struct fields only, so any key without a home here is lost on the
// next `ninja plugin init`/bump. `ninja plugin build` stamps this into the
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
// straight from plugin.mod as a fallback. OFF by default.
DataDir bool `toml:"data_dir,omitempty"`
}
type ModCompat struct {
BlockCore string `toml:"block_core"`
}
type ModRequirement struct {
Name string `toml:"name"`
Version string `toml:"version"`
}
func ParseModFull(b []byte) (*ModFile, error) {
var m ModFile
if err := tomlpkg.Unmarshal(b, &m); err != nil {
return nil, err
}
return &m, nil
}
// Coords returns the canonical display coordinate for the plugin in the form
// "@scope/name@version" (or "name@version" when no scope is set).
//
// The leading "@" on m.Plugin.Scope is intentionally trimmed before
// re-prefixing so that authors may write either "@themes" or "themes" in
// plugin.mod and get the same output. Callers that need the raw scope as
// written should read m.Plugin.Scope directly.
func (m *ModFile) Coords() string {
if m == nil {
return ""
}
if m.Plugin.Private {
return "@" + PrivateScopeSlug + "/" + m.Plugin.Name + "@" + m.Plugin.Version
}
scope := strings.TrimPrefix(m.Plugin.Scope, "@")
if scope == "" {
return m.Plugin.Name + "@" + m.Plugin.Version
}
return "@" + scope + "/" + m.Plugin.Name + "@" + m.Plugin.Version
}
// PrivateScopeSlug is the registry namespace under which all private plugins
// live. Coords for private plugins resolve to "@private/<name>@<version>";
// uniqueness is enforced by (owner_account_id, name), not by the slug.
const PrivateScopeSlug = "private"
const (
TagMinLen = 2
TagMaxLen = 30
TagMaxCount = 10
)
// tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups.
// Rejects leading/trailing/consecutive hyphens.
var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// NormalizeTags trims, lowercases, dedupes (case-insensitively), validates,
// and caps a slice of tags. Returns the cleaned slice or an error listing
// every offending input so authors fix them in one pass.
//
// Rules:
// - trim surrounding whitespace; drop empty entries silently
// - lowercase
// - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens
// - dedupe case-insensitively, preserving first occurrence order
// - at most TagMaxCount entries (counted after dedupe)
func NormalizeTags(in []string) ([]string, error) {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
var bad []string
for _, raw := range in {
t := strings.ToLower(strings.TrimSpace(raw))
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) {
bad = append(bad, raw)
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
if len(bad) > 0 {
return nil, fmt.Errorf(
"invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s",
TagMinLen, TagMaxLen, strings.Join(bad, ", "),
)
}
if len(out) > TagMaxCount {
return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount)
}
return out, nil
}

434
plugin/mod_test.go Normal file
View File

@ -0,0 +1,434 @@
package plugin
import (
"fmt"
"strings"
"testing"
)
func TestParseModFull_BasicFields(t *testing.T) {
src := []byte(`
[plugin]
name = "smartblock"
scope = "blockninja"
version = "1.4.2"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Name != "smartblock" {
t.Errorf("Name = %q, want smartblock", m.Plugin.Name)
}
if m.Plugin.Scope != "blockninja" {
t.Errorf("Scope = %q, want blockninja", m.Plugin.Scope)
}
if m.Plugin.Version != "1.4.2" {
t.Errorf("Version = %q, want 1.4.2", m.Plugin.Version)
}
if got := m.Coords(); got != "@blockninja/smartblock@1.4.2" {
t.Errorf("Coords() = %q", got)
}
}
func TestParseModFull_BackCompatNoScope(t *testing.T) {
src := []byte(`
[plugin]
name = "legacy"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Scope != "" {
t.Errorf("Scope should be empty, got %q", m.Plugin.Scope)
}
if got := m.Coords(); got != "legacy@0.1.0" {
t.Errorf("Coords() = %q, want legacy@0.1.0", got)
}
}
func TestParseModFull_InvalidTOML(t *testing.T) {
_, err := ParseModFull([]byte("not valid toml = ="))
if err == nil {
t.Fatal("expected parse error")
}
}
func TestParseModFull_EmptyInput(t *testing.T) {
m, err := ParseModFull(nil)
if err != nil {
t.Fatalf("nil input err: %v", err)
}
if m.Plugin.Name != "" {
t.Errorf("Name should be empty")
}
}
func TestParseModFull_KindAndCategories(t *testing.T) {
src := []byte(`
[plugin]
name = "analyser"
scope = "blockninja"
version = "0.1.0"
kind = "plugin"
categories = ["analytics", "seo"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Kind != "plugin" {
t.Errorf("Kind = %q, want plugin", m.Plugin.Kind)
}
if got := m.Plugin.Categories; len(got) != 2 || got[0] != "analytics" || got[1] != "seo" {
t.Errorf("Categories = %v", got)
}
}
func TestParseModFull_KindDefaultsEmpty(t *testing.T) {
src := []byte(`
[plugin]
name = "legacy"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Kind != "" {
t.Errorf("Kind should be empty for legacy mod, got %q", m.Plugin.Kind)
}
}
func TestCoords_AcceptsScopeWithOrWithoutAt(t *testing.T) {
want := "@themes/foo@1.0.0"
withAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "@themes", Version: "1.0.0"}}
if got := withAt.Coords(); got != want {
t.Errorf("Coords() with leading @ = %q, want %q", got, want)
}
withoutAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "themes", Version: "1.0.0"}}
if got := withoutAt.Coords(); got != want {
t.Errorf("Coords() without leading @ = %q, want %q", got, want)
}
}
func TestParseModFull_PrivateField(t *testing.T) {
src := []byte(`
[plugin]
name = "internal-tool"
version = "0.1.0"
private = true
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if !m.Plugin.Private {
t.Errorf("Private = false, want true")
}
}
func TestParseModFull_DataDirField(t *testing.T) {
src := []byte(`
[plugin]
name = "stateful-plugin"
version = "0.1.0"
data_dir = true
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if !m.Plugin.DataDir {
t.Errorf("DataDir = false, want true")
}
}
func TestParseModFull_DataDirDefaultsFalse(t *testing.T) {
src := []byte(`
[plugin]
name = "stateless-plugin"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.DataDir {
t.Errorf("DataDir = true, want false (absent key)")
}
}
func TestParseModFull_PrivateDefaultsFalse(t *testing.T) {
src := []byte(`
[plugin]
name = "public-thing"
scope = "themes"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Private {
t.Errorf("Private = true, want false (default)")
}
}
func TestCoords_PrivateOverridesScope(t *testing.T) {
m := &ModFile{Plugin: ModPlugin{
Name: "myplugin",
Scope: "@themes",
Version: "0.1.0",
Private: true,
}}
if got := m.Coords(); got != "@private/myplugin@0.1.0" {
t.Errorf("Coords() = %q, want @private/myplugin@0.1.0", got)
}
}
func TestCoords_PrivateNoScope(t *testing.T) {
m := &ModFile{Plugin: ModPlugin{
Name: "myplugin",
Version: "0.1.0",
Private: true,
}}
if got := m.Coords(); got != "@private/myplugin@0.1.0" {
t.Errorf("Coords() = %q, want @private/myplugin@0.1.0", got)
}
}
func TestParseModFull_RequiredIconPacks(t *testing.T) {
src := []byte(`
[plugin]
name = "neon"
version = "0.1.0"
required_icon_packs = ["tabler", "phosphor"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.RequiredIconPacks) != 2 {
t.Fatalf("RequiredIconPacks len = %d, want 2", len(m.Plugin.RequiredIconPacks))
}
if m.Plugin.RequiredIconPacks[0] != "tabler" || m.Plugin.RequiredIconPacks[1] != "phosphor" {
t.Errorf("RequiredIconPacks = %v", m.Plugin.RequiredIconPacks)
}
}
func TestParseModFull_RequiredIconPacksOmitted(t *testing.T) {
src := []byte(`
[plugin]
name = "noicons"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.RequiredIconPacks) != 0 {
t.Errorf("RequiredIconPacks should be empty when omitted, got %v", m.Plugin.RequiredIconPacks)
}
}
func TestParseRequiredIconPacks(t *testing.T) {
src := []byte(`
[plugin]
name = "neon"
version = "0.1.0"
required_icon_packs = ["tabler", " phosphor ", ""]
`)
got := ParseRequiredIconPacks(src)
if len(got) != 2 {
t.Fatalf("ParseRequiredIconPacks len = %d (%v), want 2", len(got), got)
}
if got[0] != "tabler" || got[1] != "phosphor" {
t.Errorf("ParseRequiredIconPacks = %v, want [tabler phosphor]", got)
}
}
func TestParseRequiredIconPacks_NilOnAbsent(t *testing.T) {
src := []byte(`
[plugin]
name = "noicons"
version = "0.1.0"
`)
if got := ParseRequiredIconPacks(src); got != nil {
t.Errorf("ParseRequiredIconPacks on absent field = %v, want nil", got)
}
}
func TestParseRequiredIconPacks_NilOnInvalidTOML(t *testing.T) {
if got := ParseRequiredIconPacks([]byte("not valid = = =")); got != nil {
t.Errorf("ParseRequiredIconPacks on invalid TOML = %v, want nil", got)
}
}
func TestParseModFull_RequiresAndCompat(t *testing.T) {
src := []byte(`
[plugin]
name = "symposium"
scope = "blockninja"
version = "0.2.0"
[compatibility]
block_core = ">=1.5 <2.0"
[[requires]]
name = "@blockninja/smartblock"
version = ">=1.0 <2.0"
[[requires]]
name = "@blockninja/gotham"
version = ">=1.2"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Compatibility == nil || m.Compatibility.BlockCore != ">=1.5 <2.0" {
t.Errorf("Compat = %+v", m.Compatibility)
}
if len(m.Requires) != 2 {
t.Fatalf("Requires len = %d, want 2", len(m.Requires))
}
if m.Requires[0].Name != "@blockninja/smartblock" {
t.Errorf("Requires[0].Name = %q", m.Requires[0].Name)
}
if m.Requires[1].Version != ">=1.2" {
t.Errorf("Requires[1].Version = %q", m.Requires[1].Version)
}
}
func TestNormalizeTags_HappyPath(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
if err != nil {
t.Fatalf("NormalizeTags err: %v", err)
}
want := []string{"dark", "agency", "serif"}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestNormalizeTags_LowercaseAndTrim(t *testing.T) {
got, err := NormalizeTags([]string{" Dark ", "AGENCY"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
t.Errorf("got %v, want [dark agency]", got)
}
}
func TestNormalizeTags_DedupesCaseInsensitive(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "Dark", "DARK", "agency"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
t.Errorf("got %v, want [dark agency]", got)
}
}
func TestNormalizeTags_DropsEmpty(t *testing.T) {
got, err := NormalizeTags([]string{"", "dark", " "})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 1 || got[0] != "dark" {
t.Errorf("got %v, want [dark]", got)
}
}
func TestNormalizeTags_RejectsBadSlugs(t *testing.T) {
_, err := NormalizeTags([]string{"valid", "Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)})
if err == nil {
t.Fatal("expected error")
}
for _, frag := range []string{"Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)} {
if !strings.Contains(err.Error(), frag) {
t.Errorf("error %q does not mention %q", err.Error(), frag)
}
}
}
func TestNormalizeTags_AcceptsBounds(t *testing.T) {
got, err := NormalizeTags([]string{"ab", strings.Repeat("a", 30)})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 {
t.Errorf("got %v, want both accepted", got)
}
}
func TestNormalizeTags_CapEnforced(t *testing.T) {
in := make([]string, TagMaxCount+1)
for i := range in {
in[i] = fmt.Sprintf("tag-%d", i)
}
_, err := NormalizeTags(in)
if err == nil {
t.Fatal("expected too-many-tags error")
}
if !strings.Contains(err.Error(), "too many tags") {
t.Errorf("error %q does not mention 'too many tags'", err.Error())
}
}
func TestNormalizeTags_NilInput(t *testing.T) {
got, err := NormalizeTags(nil)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestParseModFull_Tags(t *testing.T) {
src := []byte(`
[plugin]
name = "dark-pro"
scope = "themes"
version = "0.1.0"
kind = "theme"
tags = ["dark", "agency", "serif"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.Tags) != 3 {
t.Fatalf("Tags len = %d, want 3 (%v)", len(m.Plugin.Tags), m.Plugin.Tags)
}
if m.Plugin.Tags[0] != "dark" || m.Plugin.Tags[1] != "agency" || m.Plugin.Tags[2] != "serif" {
t.Errorf("Tags = %v", m.Plugin.Tags)
}
}
func TestParseModFull_TagsOmittedIsNil(t *testing.T) {
src := []byte(`
[plugin]
name = "no-tags"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("err: %v", err)
}
if m.Plugin.Tags != nil {
t.Errorf("Tags = %v, want nil when omitted", m.Plugin.Tags)
}
}

112
plugin/provisioner.go Normal file
View File

@ -0,0 +1,112 @@
package plugin
import (
"context"
"encoding/json"
"github.com/google/uuid"
)
// Provisioner allows plugins to ensure required resources exist on startup.
// All methods are idempotent — they check first and only create if missing.
type Provisioner interface {
EnsureDataTable(config DataTableConfig) error
MergeSiteSettings(defaults map[string]any) error
EnsureSetting(key string, defaultValue any) error
EnsurePage(config PageConfig) error
OverrideSiteSettings(overrides map[string]any) error
EnsureMenuItem(menuName string, config MenuItemConfig) error
RegisterEmbeddingConfig(config EmbeddingConfigDef) error
EnsureEmbed(config EmbedConfig) error
EnsureJobSchedule(config JobScheduleConfig) error
UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value any) error
DisableOrphanedJobSchedules(registeredTypes []string) error
EnsurePlugin(name string) error
EnsureCustomColor(config CustomColorConfig) error
// EnsureMedia deposits an image under the plugin-chosen MediaDeposit.ID if a
// row with that ID does not already exist. Idempotent: an existing ID is a
// no-op; if the bytes differ from what was first deposited it logs a warning
// rather than overwriting (seeded media is immutable once placed).
EnsureMedia(deposit MediaDeposit) error
}
// DataTableConfig defines a data table to provision.
type DataTableConfig struct {
Key string
Name string
Description string
Schema json.RawMessage
PrimaryKey string
}
// PageConfig defines a page to provision.
type PageConfig struct {
Slug string
ParentSlug string
Title string
TemplateKey string
Blocks []PageBlockConfig
DetailSourceType string
DetailSourceKey string
DetailSlugField string
ReconcileBlocks bool
ReconcileTemplate bool
}
// PageBlockConfig defines a block within a provisioned page.
type PageBlockConfig struct {
BlockKey string
Title string
Content map[string]any
HtmlContent *string
Slot string
SortOrder int32
}
// EmbeddingConfigDef defines embedding text generation for a data table.
type EmbeddingConfigDef struct {
TableKey string
TextTemplate string
Enabled bool
}
// MenuItemConfig defines a menu item to provision.
type MenuItemConfig struct {
Label string
URL string
PageSlug string
SortOrder int32
}
// EmbedConfig defines an embeddable component template.
type EmbedConfig struct {
Key string
Title string
Description string
Icon string
LabelField string
Template string
RenderFunc func(ctx context.Context, content map[string]any) string
DataSource EmbedDataSource
}
// EmbedDataSource specifies where embed data comes from.
type EmbedDataSource struct {
Type string
TableKey string
}
// JobScheduleConfig defines a cron schedule for a background job.
type JobScheduleConfig struct {
JobType string
CronExpression string
Config json.RawMessage
}
// CustomColorConfig defines a custom theme color variable.
type CustomColorConfig struct {
Name string
LightValue string
DarkValue string
Source string
}

54
plugin/registration.go Normal file
View File

@ -0,0 +1,54 @@
package plugin
import (
"context"
"io/fs"
"net/http"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
)
// RegisterFunc is the function signature for plugin registration.
// Plugins register their templates and blocks through the provided registries.
type RegisterFunc func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error
// PluginRegistration defines a compiled-in plugin's entry points.
type PluginRegistration struct {
Name string
Register RegisterFunc
RegisterWithProvisioner func(tr templates.TemplateRegistry, br blocks.BlockRegistry, p Provisioner) error
Assets func() http.Handler
Schemas func() fs.FS
SettingsSchema func() []byte
ThemePresets func() []byte
BundledFonts func() []byte
MasterPages func() []MasterPageDefinition
HTTPHandler func(deps CoreServices) http.Handler
SettingsPanel func() string
AdminPages func() []AdminPage
CSSManifest func() *CSSManifest
ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error)
JobHandlers func(deps CoreServices) map[string]JobHandlerFunc
AIActions func() []AIAction
DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider
Load func(deps CoreServices) error
Unload func(ctx context.Context) error
Dependencies []Dependency
Migrations func() fs.FS
Version string
// RequiredIconPacks are icon-pack slugs the CMS must ensure are installed
// before the theme loads (e.g. "tabler", "phosphor"). Loader auto-installs
// from the bundled registry; out-of-registry slugs are logged and require
// manual install.
RequiredIconPacks []string
}

23
plugin/reviews.go Normal file
View File

@ -0,0 +1,23 @@
package plugin
import (
"context"
"github.com/google/uuid"
)
// ReviewSubmitter allows plugins to submit community reviews programmatically
// without importing CMS proto types.
type ReviewSubmitter interface {
SubmitReview(ctx context.Context, params SubmitReviewParams) (reviewID string, err error)
}
// SubmitReviewParams contains the fields needed to submit a community review.
type SubmitReviewParams struct {
TableID uuid.UUID
RowID uuid.UUID
OverallRating int32
ReviewText string
Ratings map[string]any
Photos []string
}

105
plugin/seeder.go Normal file
View File

@ -0,0 +1,105 @@
package plugin
import (
"context"
"github.com/jackc/pgx/v5"
)
// SymposiumSeeder provides Symposium data-seeding and lookup operations for
// cross-plugin use via PluginBridge. Template plugins retrieve this interface
// instead of importing the Symposium database package directly.
//
// All Seed* methods are idempotent: existing rows are returned, not duplicated.
type SymposiumSeeder interface {
// Wiki
SeedWikiCategory(ctx context.Context, tx pgx.Tx, name, slug, description string, sortOrder int32) (string, error)
SeedWikiArticle(ctx context.Context, tx pgx.Tx, title, slug, categoryID, excerpt string, contentJSON []byte) error
// Courses
SeedCourse(ctx context.Context, tx pgx.Tx, p SeedCourseParams) (string, error)
SeedCourseSection(ctx context.Context, tx pgx.Tx, courseID, title string, position int32) (string, error)
SeedLesson(ctx context.Context, tx pgx.Tx, courseID, sectionID string, p SeedLessonParams) (string, error)
// Quizzes
SeedQuiz(ctx context.Context, tx pgx.Tx, title string, passThreshold int32) (string, error)
SeedQuizQuestion(ctx context.Context, tx pgx.Tx, quizID string, p SeedQuizQuestionParams) (string, error)
// Community
SeedCommunityCategory(ctx context.Context, tx pgx.Tx, name, slug, description, icon string, position, minTierLevel int32) (string, error)
// Course categories & tags
SeedCourseCategory(ctx context.Context, tx pgx.Tx, name, slug string, description *string, position int32) error
SeedCourseTag(ctx context.Context, tx pgx.Tx, name, slug string, position *int32) error
// Lookups — return the entity ID or an error if not found.
GetCourseBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error)
GetCommunityCategoryBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error)
// Runtime queries
GetUserCourseProgress(ctx context.Context, pool Pool, userID string) ([]CourseProgressItem, error)
}
// SeedCourseParams holds the parameters for seeding a course.
type SeedCourseParams struct {
Title string
Slug string
Description string
CertificateEnabled bool
MinTierLevel int32
DifficultyLevel string
EstimatedDurationMinutes int32
}
// SeedLessonParams holds the parameters for seeding a lesson.
type SeedLessonParams struct {
Title string
Slug string
LessonType string
ContentJSON []byte
QuizID string
Position int32
IsFreePreview bool
CompletionMode string
}
// SeedQuizQuestionParams holds the parameters for seeding a quiz question.
type SeedQuizQuestionParams struct {
QuestionType string
QuestionText string
Options []map[string]any
CorrectAnswer string
Explanation string
Position int32
Rubric string
ScenarioText string
}
// CourseProgressItem is a lightweight view of a user's progress in one course.
type CourseProgressItem struct {
CourseTitle string
CourseSlug string
NextLesson string
DoneCount int
TotalLessons int
}
// MessengerSeeder provides Messenger data-seeding operations for cross-plugin
// use via PluginBridge. Template plugins retrieve this interface instead of
// importing the Messenger database package directly.
type MessengerSeeder interface {
CountMessagesForPair(ctx context.Context, tx pgx.Tx, participantPairID string) (int64, error)
InsertMessage(ctx context.Context, tx pgx.Tx, p InsertMessageParams) error
}
// InsertMessageParams holds the parameters for inserting a messenger message.
type InsertMessageParams struct {
SenderType string
SenderID string
SenderDisplayName string
RecipientType string
RecipientID string
RecipientDisplayName string
BodyMarkdown string
ParticipantPairID string
}

61
plugin/service.go Normal file
View File

@ -0,0 +1,61 @@
package plugin
import (
"maps"
"net/http"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/block/pluginsdk/rbac"
)
// ServiceMount is a path + handler pair for an RPC service.
type ServiceMount struct {
Path string
Handler http.Handler
}
// ConnectServiceBinding describes a plugin RPC service mounted with the core interceptor chain.
type ConnectServiceBinding struct {
name string
build func(opts ...connect.HandlerOption) (string, http.Handler)
methodRoles map[string]rbac.Role
}
// NewConnectServiceBinding creates a service binding whose handler is always
// constructed by core with the supplied Connect handler options.
func NewConnectServiceBinding[T any](
name string,
svc T,
constructor func(T, ...connect.HandlerOption) (string, http.Handler),
methodRoles map[string]rbac.Role,
) ConnectServiceBinding {
clonedRoles := make(map[string]rbac.Role, len(methodRoles))
maps.Copy(clonedRoles, methodRoles)
return ConnectServiceBinding{
name: name,
build: func(opts ...connect.HandlerOption) (string, http.Handler) {
return constructor(svc, opts...)
},
methodRoles: clonedRoles,
}
}
func (b ConnectServiceBinding) Name() string {
return b.name
}
func (b ConnectServiceBinding) Build(opts ...connect.HandlerOption) (string, http.Handler) {
return b.build(opts...)
}
func (b ConnectServiceBinding) MethodRoles() map[string]rbac.Role {
cloned := make(map[string]rbac.Role, len(b.methodRoles))
maps.Copy(cloned, b.methodRoles)
return cloned
}
// ServiceRegistration bundles plugin RPC services with their RBAC method roles.
type ServiceRegistration struct {
Services []ConnectServiceBinding
}

64
plugin/topo_sort.go Normal file
View File

@ -0,0 +1,64 @@
package plugin
import "fmt"
// TopologicalSort orders plugins so that dependencies come before dependents.
// Returns error on circular dependencies or missing required dependencies.
func TopologicalSort(plugins []PluginRegistration) ([]PluginRegistration, error) {
byName := make(map[string]*PluginRegistration, len(plugins))
for i := range plugins {
byName[plugins[i].Name] = &plugins[i]
}
for _, p := range plugins {
for _, dep := range p.Dependencies {
if dep.Required {
if _, ok := byName[dep.Plugin]; !ok {
return nil, fmt.Errorf("plugin %q requires %q, but it is not available", p.Name, dep.Plugin)
}
}
}
}
inDegree := make(map[string]int, len(plugins))
dependents := make(map[string][]string)
for _, p := range plugins {
if _, ok := inDegree[p.Name]; !ok {
inDegree[p.Name] = 0
}
for _, dep := range p.Dependencies {
if _, ok := byName[dep.Plugin]; ok {
inDegree[p.Name]++
dependents[dep.Plugin] = append(dependents[dep.Plugin], p.Name)
}
}
}
var queue []string
for _, p := range plugins {
if inDegree[p.Name] == 0 {
queue = append(queue, p.Name)
}
}
var result []PluginRegistration
for len(queue) > 0 {
name := queue[0]
queue = queue[1:]
result = append(result, *byName[name])
for _, dep := range dependents[name] {
inDegree[dep]--
if inDegree[dep] == 0 {
queue = append(queue, dep)
}
}
}
if len(result) != len(plugins) {
return nil, fmt.Errorf("circular dependency detected among plugins")
}
return result, nil
}

121
plugin/types.go Normal file
View File

@ -0,0 +1,121 @@
package plugin
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Pool interface for database transactions (satisfied by *pgxpool.Pool).
type Pool interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
// JobHandlerFunc is the signature for background job handlers.
type JobHandlerFunc func(ctx context.Context, config json.RawMessage, progress func(current, total int, message string)) (json.RawMessage, error)
// Dependency declares that a plugin requires another plugin.
type Dependency struct {
Plugin string
MinVersion string
Required bool
}
// EmailSender is the narrow plugin-facing interface for sending emails.
type EmailSender interface {
Send(to, subject, body string) error
}
// AdminPage defines a page that a plugin registers in the admin sidebar.
type AdminPage struct {
Key string
Title string
Icon string
Route string
}
// AIAction declares an AI task registered in the AI Action Matrix.
type AIAction struct {
Key string
Title string
Description string
DefaultProvider string
DefaultModel string
}
// MasterPageBlock defines a block to add to a master page during provisioning.
type MasterPageBlock struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HtmlContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
}
// MasterPageDefinition defines a default master page that a plugin provisions.
type MasterPageDefinition struct {
Key string `json:"key"`
Title string `json:"title"`
PageTemplates []string `json:"page_templates"`
Blocks []MasterPageBlock `json:"blocks"`
}
// Plugin status constants.
const (
StatusLoaded = "loaded"
StatusFailed = "failed"
StatusDisabled = "disabled"
StatusRebuilding = "rebuilding"
StatusPendingRestart = "pending_restart"
)
// Plugin source constants.
const (
SourceBundled = "bundled"
SourceInstalled = "installed"
)
// MediaAnalyzedEvent is emitted after media scanning completes.
type MediaAnalyzedEvent struct {
MediaID uuid.UUID
AnalysisID uuid.UUID
ContentHash string
Status string
SourcePlugin string
SourceType string
SourceRefID uuid.UUID
SafeAdult string
SafeViolence string
SafeRacy string
}
// ModerationDecisionEvent is emitted when a moderation decision is made.
type ModerationDecisionEvent struct {
MediaID uuid.UUID
AnalysisID uuid.UUID
Status string
PreviousStatus string
SourcePlugin string
SourceType string
SourceRefID uuid.UUID
ModeratedBy uuid.UUID
Note string
}
// MediaHooksProvider is the interface plugins implement to receive media lifecycle events.
type MediaHooksProvider interface {
OnMediaAnalyzed(ctx context.Context, event MediaAnalyzedEvent) error
OnModerationDecision(ctx context.Context, event ModerationDecisionEvent) error
}
// DirectoryExtensions allows plugins to customize the directory handler UI.
type DirectoryExtensions struct {
PanelSections []func(data map[string]any) string
PinDecorators []func(pin map[string]any, data map[string]any)
BooleanFilterFields []string
SelectFilterFields []string
BadgeLabels map[string][2]string
}

116
plugin/version.go Normal file
View File

@ -0,0 +1,116 @@
package plugin
import (
"bufio"
"bytes"
"fmt"
"strconv"
"strings"
"golang.org/x/mod/semver"
)
// ParseModVersion extracts the version string from an embedded plugin.mod file.
// Returns "0.0.0" if parsing fails or version is not found.
func ParseModVersion(data []byte) string {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "version") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, `"`)
if val != "" {
return val
}
}
}
return "0.0.0"
}
// ParseRequiredIconPacks extracts the required_icon_packs list from an embedded
// plugin.mod file. Returns nil if the field is absent or empty. Mirrors the
// "read straight from TOML bytes" style of ParseModVersion so plugin
// registration.go files can populate PluginRegistration.RequiredIconPacks
// without having to duplicate the slugs in Go.
func ParseRequiredIconPacks(data []byte) []string {
m, err := ParseModFull(data)
if err != nil || m == nil {
return nil
}
if len(m.Plugin.RequiredIconPacks) == 0 {
return nil
}
out := make([]string, 0, len(m.Plugin.RequiredIconPacks))
for _, slug := range m.Plugin.RequiredIconPacks {
s := strings.TrimSpace(slug)
if s == "" {
continue
}
out = append(out, s)
}
if len(out) == 0 {
return nil
}
return out
}
// CompareVersions compares two semver strings.
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
// Returns 0 if either version is invalid.
func CompareVersions(v1, v2 string) int {
if !strings.HasPrefix(v1, "v") {
v1 = "v" + v1
}
if !strings.HasPrefix(v2, "v") {
v2 = "v" + v2
}
if !semver.IsValid(v1) || !semver.IsValid(v2) {
return 0
}
return semver.Compare(v1, v2)
}
// ParseBaseSemver parses a plain MAJOR.MINOR.PATCH version into integers.
// Rejects pre-release suffixes and build metadata.
func ParseBaseSemver(s string) (major, minor, patch int, err error) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s)
}
out := [3]int{}
for i, p := range parts {
n, perr := strconv.Atoi(p)
if perr != nil || n < 0 {
return 0, 0, 0, fmt.Errorf("invalid version %q: each part must be a non-negative integer", s)
}
out[i] = n
}
return out[0], out[1], out[2], nil
}
// BumpVersion returns current bumped at the given level ("major", "minor", or "patch").
// Bumping major resets minor and patch to 0; bumping minor resets patch to 0.
func BumpVersion(current, level string) (string, error) {
major, minor, patch, err := ParseBaseSemver(current)
if err != nil {
return "", err
}
switch level {
case "major":
major++
minor = 0
patch = 0
case "minor":
minor++
patch = 0
case "patch":
patch++
default:
return "", fmt.Errorf("unknown bump level %q (want major|minor|patch)", level)
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
}

77
plugin/version_test.go Normal file
View File

@ -0,0 +1,77 @@
package plugin
import "testing"
func TestParseBaseSemver(t *testing.T) {
cases := []struct {
in string
major, minor, patch int
wantErr bool
}{
{"0.1.0", 0, 1, 0, false},
{"1.0.0", 1, 0, 0, false},
{"12.34.567", 12, 34, 567, false},
{"0.0.0", 0, 0, 0, false},
{"v0.1.0", 0, 0, 0, true},
{"0.1", 0, 0, 0, true},
{"0.1.0.0", 0, 0, 0, true},
{"0.1.0-beta", 0, 0, 0, true},
{"0.1.0+build", 0, 0, 0, true},
{"-1.0.0", 0, 0, 0, true},
{"a.b.c", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
maj, min, pat, err := ParseBaseSemver(c.in)
if c.wantErr {
if err == nil {
t.Fatalf("ParseBaseSemver(%q): expected error, got %d.%d.%d", c.in, maj, min, pat)
}
return
}
if err != nil {
t.Fatalf("ParseBaseSemver(%q): unexpected error: %v", c.in, err)
}
if maj != c.major || min != c.minor || pat != c.patch {
t.Errorf("ParseBaseSemver(%q) = %d.%d.%d, want %d.%d.%d", c.in, maj, min, pat, c.major, c.minor, c.patch)
}
})
}
}
func TestBumpVersion(t *testing.T) {
cases := []struct {
current, level, want string
wantErr bool
}{
{"0.1.0", "patch", "0.1.1", false},
{"0.1.0", "minor", "0.2.0", false},
{"0.1.0", "major", "1.0.0", false},
{"1.2.3", "patch", "1.2.4", false},
{"1.2.3", "minor", "1.3.0", false},
{"1.2.3", "major", "2.0.0", false},
{"0.0.0", "patch", "0.0.1", false},
{"0.1.0", "build", "", true},
{"0.1.0", "", "", true},
{"v0.1.0", "patch", "", true},
{"", "patch", "", true},
}
for _, c := range cases {
t.Run(c.current+"/"+c.level, func(t *testing.T) {
got, err := BumpVersion(c.current, c.level)
if c.wantErr {
if err == nil {
t.Fatalf("BumpVersion(%q, %q): expected error, got %q", c.current, c.level, got)
}
return
}
if err != nil {
t.Fatalf("BumpVersion(%q, %q): unexpected error: %v", c.current, c.level, err)
}
if got != c.want {
t.Errorf("BumpVersion(%q, %q) = %q, want %q", c.current, c.level, got, c.want)
}
})
}
}

View File

@ -0,0 +1,324 @@
package bnwasm
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/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 []uuid.UUID:
// uuid[] gets its own variant so the host binds a native uuid[]
// parameter (queries keep `ANY($1::uuid[])` — no text[]-cast
// workaround). nil → NULL / empty stays a non-NULL empty uuid[],
// mirroring the []string convention.
if v == nil {
return nullValue(), nil
}
strs := make([]string, len(v))
for i, id := range v {
strs[i] = id.String()
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: strs}}}, 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)
// uuid_array → ([]string, false) // canonical forms; assign parses into []uuid.UUID
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
case *abiv1.DbValue_UuidArrayValue:
// Raw canonical strings, mirroring the single-uuid arm returning a
// string: assign() parses them into a []uuid.UUID destination (with
// error propagation), the same way uuid.UUID.Scan parses the scalar.
return append([]string(nil), k.UuidArrayValue.GetValues()...), false
default:
return nil, true
}
}
// uuidSliceType is the reflect.Type of []uuid.UUID, the canonical scan
// destination sqlc emits for a uuid[] column (and COALESCE(...::uuid[]) arg).
var uuidSliceType = reflect.TypeFor[[]uuid.UUID]()
// 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)
// uuid[] natural value ([]string of canonical UUIDs) → []uuid.UUID: parse
// each element (mirrors uuid.UUID.Scan for the scalar). reflect can neither
// assign nor convert []string to []uuid.UUID, so this must be explicit.
if dst.Type() == uuidSliceType {
if strs, ok := nv.([]string); ok {
out := make([]uuid.UUID, len(strs))
for i, s := range strs {
id, err := uuid.Parse(s)
if err != nil {
return fmt.Errorf("bnwasm: cannot scan %q into uuid at index %d: %w", s, i, err)
}
out[i] = id
}
dst.Set(reflect.ValueOf(out))
return nil
}
}
// 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,203 @@
package bnwasm
import (
"encoding/json"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/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")
// FixtureUUIDs are the fixed, ORDERED UUIDs used by the uuid_array fixture; the
// round-trip must preserve both their values and their order.
var FixtureUUIDs = []uuid.UUID{
uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001"),
uuid.MustParse("bbbbbbbb-0000-0000-0000-000000000002"),
uuid.MustParse("cccccccc-0000-0000-0000-000000000003"),
}
// 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"}`,
},
{
// uuid[]: three ordered UUIDs bound as a native uuid[] parameter and
// scanned back into []uuid.UUID. Ordering is part of the contract — the
// host mirror (dbexec.go) must preserve element order.
Name: "uuid_array",
Value: &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: []string{
FixtureUUIDs[0].String(), FixtureUUIDs[1].String(), FixtureUUIDs[2].String(),
}}}},
NewDest: func() any { return new([]uuid.UUID) },
Want: FixtureUUIDs,
DriverValue: `{"aaaaaaaa-0000-0000-0000-000000000001","bbbbbbbb-0000-0000-0000-000000000002","cccccccc-0000-0000-0000-000000000003"}`,
},
// --- 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,119 @@
package bnwasm
import (
"encoding/json"
"reflect"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"github.com/google/uuid"
)
// 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)
}
})
}
// TestToDbValueUuidArray pins the guest-side arg encoding for []uuid.UUID: a nil
// slice marshals to SQL NULL, an empty-but-non-nil slice stays a non-NULL empty
// uuid[], and values marshal to canonical strings in order. The host binds this
// as a native uuid[] parameter (no text[]-cast workaround).
func TestToDbValueUuidArray(t *testing.T) {
t.Run("nil", func(t *testing.T) {
dv, err := toDbValue([]uuid.UUID(nil))
if err != nil {
t.Fatal(err)
}
if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok {
t.Fatalf("nil []uuid.UUID: want DbValue_Null, got %T", dv.GetKind())
}
})
t.Run("empty", func(t *testing.T) {
dv, err := toDbValue([]uuid.UUID{})
if err != nil {
t.Fatal(err)
}
ua, ok := dv.GetKind().(*abiv1.DbValue_UuidArrayValue)
if !ok {
t.Fatalf("empty []uuid.UUID: want DbValue_UuidArrayValue, got %T", dv.GetKind())
}
if got := ua.UuidArrayValue.GetValues(); len(got) != 0 {
t.Fatalf("empty []uuid.UUID: want zero-length uuid[], got %#v", got)
}
})
t.Run("values", func(t *testing.T) {
dv, err := toDbValue(FixtureUUIDs)
if err != nil {
t.Fatal(err)
}
ua, ok := dv.GetKind().(*abiv1.DbValue_UuidArrayValue)
if !ok {
t.Fatalf("want DbValue_UuidArrayValue, got %T", dv.GetKind())
}
want := []string{FixtureUUIDs[0].String(), FixtureUUIDs[1].String(), FixtureUUIDs[2].String()}
if got := ua.UuidArrayValue.GetValues(); !reflect.DeepEqual(got, want) {
t.Fatalf("want %v, got %#v", want, 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/pluginsdk/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,300 @@
package bnwasm
import (
"context"
"database/sql"
"errors"
"reflect"
"slices"
"testing"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/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 func() { _ = 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 {
return slices.Contains(s, v)
}

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/pluginsdk/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/pluginsdk/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, ...any) (pgconn.CommandTag, error)
Query(context.Context, string, ...any) (pgx.Rows, error)
QueryRow(context.Context, string, ...any) 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,194 @@
// 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/pluginsdk/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()}
}
// ErrTxExpired is returned by a db.* call whose transaction handle the host
// has already expired — it drops the handle at the call-chain deadline
// (WO-WZ-007) so a guest can never pin a connection. Unlike a real fault this
// is retryable: re-run the unit of work in a fresh transaction. The host
// signals it with ABI_ERROR_CODE_TX_EXPIRED (cms dbexec side, adopted
// separately); the transport wraps ErrTxExpired around the raw host error so
// callers can `errors.Is(err, bnwasm.ErrTxExpired)`.
var ErrTxExpired = errors.New("bnwasm: transaction expired; retry in a new transaction")
// abiCoded is satisfied by *wasmguest.HostError (its AbiErrorCode method)
// without importing that wasip1-only package, so bnwasm can classify a
// transport error by its ABI code across the sandbox boundary.
type abiCoded interface {
AbiErrorCode() abiv1.AbiErrorCode
}
// mapTransportErr translates a transport-level ABI error into a bnwasm
// sentinel where one exists (TX_EXPIRED → ErrTxExpired), leaving every other
// error untouched. Applied to the raw t(...) error at each db.* call site.
func mapTransportErr(err error) error {
if err == nil {
return nil
}
var coded abiCoded
if errors.As(err, &coded) && coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED {
return fmt.Errorf("%w: %v", ErrTxExpired, err)
}
return err
}
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, mapTransportErr(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{}, mapTransportErr(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, mapTransportErr(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 mapTransportErr(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 mapTransportErr(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,74 @@
package bnwasm
import (
"context"
"errors"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"google.golang.org/protobuf/proto"
)
// fakeHostErr mirrors *wasmguest.HostError's AbiErrorCode() method so the
// transport's cross-boundary classification (mapTransportErr) can be exercised
// natively, without importing the wasip1-only wasmguest package.
type fakeHostErr struct {
code abiv1.AbiErrorCode
msg string
}
func (e *fakeHostErr) Error() string { return e.msg }
func (e *fakeHostErr) AbiErrorCode() abiv1.AbiErrorCode { return e.code }
// transportReturning builds a Transport whose every call fails with err.
func transportReturning(err error) Transport {
return func(_ string, _, _ proto.Message) error { return err }
}
func TestTransportMapsTxExpiredToSentinel(t *testing.T) {
hostErr := &fakeHostErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED, msg: "host: tx handle expired"}
tr := transportReturning(hostErr)
ctx := context.Background()
// Every db.* verb that crosses the transport must classify TX_EXPIRED.
t.Run("query", func(t *testing.T) {
_, err := tr.query(ctx, "SELECT 1", 7, nil)
if !errors.Is(err, ErrTxExpired) {
t.Fatalf("query err = %v, want ErrTxExpired", err)
}
})
t.Run("exec", func(t *testing.T) {
_, err := tr.exec(ctx, "DELETE FROM t", 7, nil)
if !errors.Is(err, ErrTxExpired) {
t.Fatalf("exec err = %v, want ErrTxExpired", err)
}
})
t.Run("txBegin", func(t *testing.T) {
_, err := tr.txBegin(ctx)
if !errors.Is(err, ErrTxExpired) {
t.Fatalf("txBegin err = %v, want ErrTxExpired", err)
}
})
t.Run("txCommit", func(t *testing.T) {
if err := tr.txCommit(ctx, 7); !errors.Is(err, ErrTxExpired) {
t.Fatalf("txCommit err = %v, want ErrTxExpired", err)
}
})
t.Run("txRollback", func(t *testing.T) {
if err := tr.txRollback(ctx, 7); !errors.Is(err, ErrTxExpired) {
t.Fatalf("txRollback err = %v, want ErrTxExpired", err)
}
})
}
func TestTransportLeavesOtherAbiErrorsUntouched(t *testing.T) {
hostErr := &fakeHostErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, msg: "boom"}
tr := transportReturning(hostErr)
_, err := tr.query(context.Background(), "SELECT 1", 0, nil)
if errors.Is(err, ErrTxExpired) {
t.Fatalf("non-tx-expired error was misclassified as ErrTxExpired: %v", err)
}
if !errors.Is(err, error(hostErr)) {
t.Fatalf("original host error not preserved: %v", err)
}
}

View File

@ -0,0 +1,69 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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 AND records the full definition locally so the host can
// execute the Handler via HOOK_AI_TOOL_CALL. Register tools in Register (every
// pooled instance runs it), not Load (one instance only) — see
// core/docs/wasm-abi.md §"Per-instance state". Instances are single-threaded,
// so the plain map needs no locking.
type aiStub struct {
base
tools map[string]*ai.ToolDefinition // slug → definition (with Handler)
}
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
}
if s.tools == nil {
s.tools = make(map[string]*ai.ToolDefinition)
}
s.tools[tool.Slug] = tool
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{})
}
// Tool returns the registered tool definition for a slug, for
// HOOK_AI_TOOL_CALL dispatch by the wasm shim.
func (s *aiStub) Tool(slug string) (*ai.ToolDefinition, bool) {
t, ok := s.tools[slug]
return t, ok
}
// 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,120 @@
package caps
import (
"context"
"encoding/json"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/content"
"github.com/google/uuid"
)
// authorStub implements content.Author over the content.* write capability
// calls (WO-WZ-019). Same family as contentStub; split so the read surface
// stays untouched.
type authorStub struct{ base }
var _ content.Author = (*authorStub)(nil)
func (s *authorStub) CreatePage(ctx context.Context, params content.CreatePageParams) (content.CreatePageResult, error) {
req := &abiv1.ContentCreatePageRequest{
Slug: params.Slug,
ParentSlug: params.ParentSlug,
Title: params.Title,
TemplateKey: params.TemplateKey,
MasterPageKey: params.MasterPageKey,
}
resp := &abiv1.ContentCreatePageResponse{}
if err := s.invoke(ctx, "create_page", req, resp); err != nil {
return content.CreatePageResult{}, err
}
id, err := uuid.Parse(resp.GetPageId())
if err != nil {
return content.CreatePageResult{}, fmt.Errorf("content.create_page: bad page_id %q: %w", resp.GetPageId(), err)
}
return content.CreatePageResult{PageID: id, Created: resp.GetCreated()}, nil
}
func (s *authorStub) SetPageBlocks(ctx context.Context, pageID uuid.UUID, blocks []content.PageBlock) error {
req := &abiv1.ContentSetPageBlocksRequest{PageId: pageID.String()}
for _, b := range blocks {
pb, err := pageBlockToProto(b)
if err != nil {
return fmt.Errorf("content.set_page_blocks: block %q: %w", b.BlockKey, err)
}
req.Blocks = append(req.Blocks, pb)
}
return s.invoke(ctx, "set_page_blocks", req, &abiv1.ContentSetPageBlocksResponse{})
}
func (s *authorStub) PublishPage(ctx context.Context, pageID uuid.UUID) error {
req := &abiv1.ContentPublishPageRequest{PageId: pageID.String()}
return s.invoke(ctx, "publish_page", req, &abiv1.ContentPublishPageResponse{})
}
func (s *authorStub) SetPageSEO(ctx context.Context, pageID uuid.UUID, seo content.PageSEO) error {
req := &abiv1.ContentSetPageSeoRequest{
PageId: pageID.String(),
MetaTitle: seo.MetaTitle,
MetaDescription: seo.MetaDescription,
OgTitle: seo.OGTitle,
OgDescription: seo.OGDescription,
OgImage: seo.OGImage,
FocusKeyphrase: seo.FocusKeyphrase,
CanonicalUrl: seo.CanonicalURL,
RobotsDirective: seo.RobotsDirective,
TwitterTitle: seo.TwitterTitle,
TwitterDescription: seo.TwitterDescription,
TwitterImage: seo.TwitterImage,
}
return s.invoke(ctx, "set_page_seo", req, &abiv1.ContentSetPageSeoResponse{})
}
func (s *authorStub) UpsertPost(ctx context.Context, params content.UpsertPostParams) (content.UpsertPostResult, error) {
docJSON, err := json.Marshal(params.Document)
if err != nil {
return content.UpsertPostResult{}, fmt.Errorf("content.upsert_post: marshal document: %w", err)
}
req := &abiv1.ContentUpsertPostRequest{
Slug: params.Slug,
Title: params.Title,
DocumentJson: docJSON,
Excerpt: params.Excerpt,
Publish: params.Publish,
IsFeatured: params.IsFeatured,
}
if params.AuthorProfileID != nil {
v := params.AuthorProfileID.String()
req.AuthorProfileId = &v
}
if params.FeaturedImageID != nil {
v := params.FeaturedImageID.String()
req.FeaturedImageId = &v
}
resp := &abiv1.ContentUpsertPostResponse{}
if err := s.invoke(ctx, "upsert_post", req, resp); err != nil {
return content.UpsertPostResult{}, err
}
id, err := uuid.Parse(resp.GetPostId())
if err != nil {
return content.UpsertPostResult{}, fmt.Errorf("content.upsert_post: bad post_id %q: %w", resp.GetPostId(), err)
}
return content.UpsertPostResult{PostID: id, Created: resp.GetCreated()}, nil
}
// pageBlockToProto converts one content.PageBlock to its wire form.
func pageBlockToProto(b content.PageBlock) (*abiv1.PageBlock, error) {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return nil, err
}
return &abiv1.PageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
ContentJson: contentJSON,
HtmlContent: b.HTMLContent,
Slot: b.Slot,
SortOrder: b.SortOrder,
}, nil
}

View File

@ -0,0 +1,20 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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,74 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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 to the
// host AND records the value locally so this plugin's own services are
// dispatchable via HOOK_BRIDGE_CALL; GetService returns nil for remote
// services (availability is checked but a typed value cannot be reconstructed
// guest-side). Cross-plugin calls use Invoke (bridge.invoke) with opaque
// payloads instead.
//
// Instances are single-threaded, so the plain map needs no locking; register
// bridge services in Register (every pooled instance runs it), not Load
// (which fires on one instance only) — see core/docs/wasm-abi.md
// §"Per-instance state".
type bridgeStub struct {
base
services map[string]any // serviceName → registered value (this plugin's own)
}
var _ plugin.PluginBridge = (*bridgeStub)(nil)
// RegisterService has no error channel; a transport failure is dropped.
func (s *bridgeStub) RegisterService(pluginName, serviceName string, service any) {
if s.services == nil {
s.services = make(map[string]any)
}
s.services[serviceName] = service
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 for remote
// services. Callers using plugin.GetServiceAs correctly observe (zero, false);
// cross-plugin calls go through Invoke.
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
}
// Invoke calls a method on another plugin's registered bridge service with an
// opaque payload (bridge.invoke; the provider answers via HOOK_BRIDGE_CALL or
// its in-process plugin.BridgeInvokable).
func (s *bridgeStub) Invoke(ctx context.Context, pluginName, serviceName, method string, payload []byte) ([]byte, error) {
req := &abiv1.BridgeInvokeRequest{
PluginName: pluginName,
ServiceName: serviceName,
Method: method,
Payload: payload,
}
resp := &abiv1.BridgeInvokeResponse{}
if err := s.invoke(ctx, "invoke", req, resp); err != nil {
return nil, err
}
return resp.GetPayload(), nil
}
// Service returns this plugin's own registered service value, for
// HOOK_BRIDGE_CALL dispatch by the wasm shim.
func (s *bridgeStub) Service(serviceName string) (any, bool) {
v, ok := s.services[serviceName]
return v, ok
}

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/pluginsdk/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,957 @@
package caps
import (
"context"
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/ai"
"git.dev.alexdunmow.com/block/pluginsdk/content"
"git.dev.alexdunmow.com/block/pluginsdk/gating"
"git.dev.alexdunmow.com/block/pluginsdk/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_list_posts", wantMethod: "content.list_posts",
resp: &abiv1.ContentListPostsResponse{Posts: []*abiv1.PostInfo{
{
Id: idAuthor.String(), Slug: "first", Title: "First", Excerpt: "one",
FeaturedImageUrl: "media:1", AuthorId: idAuthor.String(),
AuthorName: "Ada", AuthorSlug: "ada", PublishedAt: timestamppb.New(planTime),
},
{
Id: idPlan.String(), Slug: "second", Title: "Second", Excerpt: "two",
AuthorId: idAuthor.String(), AuthorName: "Ada", AuthorSlug: "ada",
PublishedAt: timestamppb.New(planTime),
},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.ListPosts(ctx, content.ListPostsParams{Limit: 10, IncludeExcerpt: true})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Slug != "first" || got[0].AuthorName != "Ada" {
t.Fatalf("posts = %+v", got)
}
if got[1].Slug != "second" || !got[1].PublishedAt.Equal(planTime) {
t.Errorf("post[1] = %+v", got[1])
}
},
},
{
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: new(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)
}
},
},
// --- content writes (WO-WZ-019) ---
{
name: "content_create_page", wantMethod: "content.create_page",
resp: &abiv1.ContentCreatePageResponse{PageId: idItem.String(), Created: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.ContentAuthor.CreatePage(ctx, content.CreatePageParams{
Slug: "/pricing", ParentSlug: "", Title: "Pricing", TemplateKey: "landing",
})
if err != nil || got.PageID != idItem || !got.Created {
t.Errorf("create_page = %+v err = %v", got, err)
}
},
},
{
name: "content_set_page_blocks", wantMethod: "content.set_page_blocks",
resp: &abiv1.ContentSetPageBlocksResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
html := "<h1>Hi</h1>"
err := cs.ContentAuthor.SetPageBlocks(ctx, idItem, []content.PageBlock{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"align": "center"}, HTMLContent: &html, Slot: "main", SortOrder: 1},
{BlockKey: "cta", Title: "CTA", Content: map[string]any{"label": "Go"}, SortOrder: 2},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "content_publish_page", wantMethod: "content.publish_page",
resp: &abiv1.ContentPublishPageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.ContentAuthor.PublishPage(ctx, idItem); err != nil {
t.Fatal(err)
}
},
},
{
name: "content_set_page_seo", wantMethod: "content.set_page_seo",
resp: &abiv1.ContentSetPageSeoResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
mt, md := "Pricing — Acme", "Plans and pricing."
err := cs.ContentAuthor.SetPageSEO(ctx, idItem, content.PageSEO{MetaTitle: &mt, MetaDescription: &md})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "content_upsert_post", wantMethod: "content.upsert_post",
resp: &abiv1.ContentUpsertPostResponse{PostId: idRow.String(), Created: false},
run: func(t *testing.T, cs plugin.CoreServices) {
excerpt := "hello"
got, err := cs.ContentAuthor.UpsertPost(ctx, content.UpsertPostParams{
Slug: "hello", Title: "Hello", Document: map[string]any{"type": "doc"},
Excerpt: &excerpt, AuthorProfileID: &idAuthor, FeaturedImageID: &idMedia,
Publish: true,
})
if err != nil || got.PostID != idRow || got.Created {
t.Errorf("upsert_post = %+v err = %v", got, err)
}
},
},
// --- settings.update_plugin_settings (WO-WZ-019) ---
{
name: "settings_update_plugin_settings", wantMethod: "settings.update_plugin_settings",
resp: &abiv1.SettingsUpdatePluginSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.SettingsUpdater.UpdatePluginSettings(ctx, "myplugin", map[string]any{"enabled": true})
if err != nil {
t.Fatal(err)
}
},
},
// --- provisioner (WO-WZ-019) ---
{
name: "provisioner_ensure_page", wantMethod: "provisioner.ensure_page",
resp: &abiv1.ProvisionerEnsurePageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsurePage(plugin.PageConfig{
Slug: "/", Title: "Home", TemplateKey: "homepage", ReconcileBlocks: true,
Blocks: []plugin.PageBlockConfig{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"x": float64(1)}, Slot: "main", SortOrder: 1},
},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_merge_site_settings", wantMethod: "provisioner.merge_site_settings",
resp: &abiv1.ProvisionerMergeSiteSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.MergeSiteSettings(map[string]any{"site_name": "Acme"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_menu_item", wantMethod: "provisioner.ensure_menu_item",
resp: &abiv1.ProvisionerEnsureMenuItemResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureMenuItem("main", plugin.MenuItemConfig{Label: "Docs", PageSlug: "/docs", SortOrder: 3})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_media", wantMethod: "provisioner.ensure_media",
resp: &abiv1.ProvisionerEnsureMediaResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureMedia(plugin.MediaDeposit{
ID: idMedia, Filename: "hero.jpg", Data: []byte{1, 2, 3}, AltText: "hero", Folder: "seed", Source: "myplugin",
})
if err != nil {
t.Fatal(err)
}
},
},
// --- bridge.invoke (WO-WZ-019) ---
{
name: "bridge_invoke", wantMethod: "bridge.invoke",
resp: &abiv1.BridgeInvokeResponse{Payload: []byte(`{"ok":true}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Bridge.Invoke(ctx, "symposium", "seeder", "seed_forum", []byte(`{"n":1}`))
if err != nil || string(got) != `{"ok":true}` {
t.Errorf("bridge.invoke = %q err = %v", got, err)
}
},
},
// --- remaining provisioner methods (WO-WZ-019) ---
{
name: "provisioner_ensure_data_table", wantMethod: "provisioner.ensure_data_table",
resp: &abiv1.ProvisionerEnsureDataTableResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureDataTable(plugin.DataTableConfig{
Key: "playgrounds", Name: "Playgrounds", Description: "d",
Schema: []byte(`{"fields":[]}`), PrimaryKey: "id",
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_setting", wantMethod: "provisioner.ensure_setting",
resp: &abiv1.ProvisionerEnsureSettingResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.EnsureSetting("review_dimensions", map[string]any{"max": float64(5)}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_override_site_settings", wantMethod: "provisioner.override_site_settings",
resp: &abiv1.ProvisionerOverrideSiteSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.OverrideSiteSettings(map[string]any{"default_template": "homepage"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_register_embedding_config", wantMethod: "provisioner.register_embedding_config",
resp: &abiv1.ProvisionerRegisterEmbeddingConfigResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.RegisterEmbeddingConfig(plugin.EmbeddingConfigDef{
TableKey: "playgrounds", TextTemplate: "{{name}}", Enabled: true,
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_embed", wantMethod: "provisioner.ensure_embed",
resp: &abiv1.ProvisionerEnsureEmbedResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureEmbed(plugin.EmbedConfig{
Key: "playground-card", Title: "Playground Card", Description: "d",
Icon: "📍", LabelField: "name", Template: "<div>{{name}}</div>",
DataSource: plugin.EmbedDataSource{Type: "row", TableKey: "playgrounds"},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_job_schedule", wantMethod: "provisioner.ensure_job_schedule",
resp: &abiv1.ProvisionerEnsureJobScheduleResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureJobSchedule(plugin.JobScheduleConfig{
JobType: "bounty_rotation", CronExpression: "0 3 * * *", Config: []byte(`{"n":3}`),
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_update_data_table_row_field", wantMethod: "provisioner.update_data_table_row_field",
resp: &abiv1.ProvisionerUpdateDataTableRowFieldResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.UpdateDataTableRowField(ctx, idRow, "status", "active"); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_disable_orphaned_job_schedules", wantMethod: "provisioner.disable_orphaned_job_schedules",
resp: &abiv1.ProvisionerDisableOrphanedJobSchedulesResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.DisableOrphanedJobSchedules([]string{"bounty_rotation", "reindex"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_plugin", wantMethod: "provisioner.ensure_plugin",
resp: &abiv1.ProvisionerEnsurePluginResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.EnsurePlugin("symposium"); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_custom_color", wantMethod: "provisioner.ensure_custom_color",
resp: &abiv1.ProvisionerEnsureCustomColorResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureCustomColor(plugin.CustomColorConfig{
Name: "brand", LightValue: "oklch(0.7 0.1 200)", DarkValue: "oklch(0.4 0.1 200)", Source: "myplugin",
})
if err != nil {
t.Fatal(err)
}
},
},
// --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a
// CoreServices stub, so the case drives the transport directly) ---
{
name: "jobs_progress", wantMethod: "jobs.progress",
resp: &abiv1.JobsProgressResponse{},
run: func(t *testing.T, _ plugin.CoreServices) {
req := &abiv1.JobsProgressRequest{Current: 2, Total: 5, Message: "halfway"}
if err := f.call("jobs.progress", req, &abiv1.JobsProgressResponse{}); 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,141 @@
package caps
import (
"context"
"encoding/json"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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
}
info := postInfoFromProto(p)
return &info, nil
}
func (s *contentStub) ListPosts(ctx context.Context, params content.ListPostsParams) ([]content.PostInfo, error) {
req := &abiv1.ContentListPostsRequest{
Limit: int32(params.Limit),
Offset: int32(params.Offset),
Category: params.Category,
PublishedOnly: params.PublishedOnly,
IncludeBody: params.IncludeBody,
IncludeExcerpt: params.IncludeExcerpt,
}
resp := &abiv1.ContentListPostsResponse{}
if err := s.invoke(ctx, "list_posts", req, resp); err != nil {
return nil, err
}
out := make([]content.PostInfo, 0, len(resp.GetPosts()))
for _, p := range resp.GetPosts() {
out = append(out, postInfoFromProto(p))
}
return out, nil
}
// postInfoFromProto decodes an abiv1.PostInfo into the SDK content.PostInfo,
// shared by GetPost and ListPosts.
func postInfoFromProto(p *abiv1.PostInfo) content.PostInfo {
var publishedAt time.Time
if ts := p.GetPublishedAt(); ts != nil {
publishedAt = ts.AsTime()
}
return content.PostInfo{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
Excerpt: p.GetExcerpt(),
Body: p.GetBody(),
FeaturedImageURL: p.GetFeaturedImageUrl(),
AuthorID: parseUUID(p.GetAuthorId()),
AuthorName: p.GetAuthorName(),
AuthorSlug: p.GetAuthorSlug(),
PublishedAt: publishedAt,
}
}
// 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,51 @@
package caps
import (
"git.dev.alexdunmow.com/block/pluginsdk/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: base{family: "settings", call: call}}
ai := &aiStub{base: base{family: "ai", call: call}}
return plugin.CoreServices{
Content: &contentStub{base{family: "content", call: call}},
ContentAuthor: &authorStub{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: 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),
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
}
}

View File

@ -0,0 +1,30 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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,189 @@
package caps
import (
"context"
"encoding/json"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"github.com/google/uuid"
)
// provisionerStub implements plugin.Provisioner over provisioner.* capability
// calls (WO-WZ-019). This is a LOAD-TIME capability: call it from the Load
// hook. RegisterWithProvisioner still receives a no-op at Register/DESCRIBE
// time (host functions are stubbed to fail there), so seed logic must move to
// Load — see core/docs/wasm-abi.md.
//
// Most Provisioner methods carry no context (the Go interface predates the
// ABI); calls run under the ambient invoke deadline the host enforces.
type provisionerStub struct{ base }
var _ plugin.Provisioner = (*provisionerStub)(nil)
func (s *provisionerStub) EnsureDataTable(config plugin.DataTableConfig) error {
req := &abiv1.ProvisionerEnsureDataTableRequest{
Key: config.Key,
Name: config.Name,
Description: config.Description,
SchemaJson: config.Schema,
PrimaryKey: config.PrimaryKey,
}
return s.invoke(context.Background(), "ensure_data_table", req, &abiv1.ProvisionerEnsureDataTableResponse{})
}
func (s *provisionerStub) MergeSiteSettings(defaults map[string]any) error {
defaultsJSON, err := json.Marshal(defaults)
if err != nil {
return fmt.Errorf("provisioner.merge_site_settings: marshal defaults: %w", err)
}
req := &abiv1.ProvisionerMergeSiteSettingsRequest{DefaultsJson: defaultsJSON}
return s.invoke(context.Background(), "merge_site_settings", req, &abiv1.ProvisionerMergeSiteSettingsResponse{})
}
func (s *provisionerStub) EnsureSetting(key string, defaultValue any) error {
valueJSON, err := json.Marshal(defaultValue)
if err != nil {
return fmt.Errorf("provisioner.ensure_setting: marshal value: %w", err)
}
req := &abiv1.ProvisionerEnsureSettingRequest{Key: key, DefaultValueJson: valueJSON}
return s.invoke(context.Background(), "ensure_setting", req, &abiv1.ProvisionerEnsureSettingResponse{})
}
func (s *provisionerStub) EnsurePage(config plugin.PageConfig) error {
seed := &abiv1.PageSeed{
Slug: config.Slug,
ParentSlug: config.ParentSlug,
Title: config.Title,
TemplateKey: config.TemplateKey,
DetailSourceType: config.DetailSourceType,
DetailSourceKey: config.DetailSourceKey,
DetailSlugField: config.DetailSlugField,
ReconcileBlocks: config.ReconcileBlocks,
ReconcileTemplate: config.ReconcileTemplate,
}
for _, b := range config.Blocks {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return fmt.Errorf("provisioner.ensure_page: block %q: marshal content: %w", b.BlockKey, err)
}
seed.Blocks = append(seed.Blocks, &abiv1.PageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
ContentJson: contentJSON,
HtmlContent: b.HtmlContent,
Slot: b.Slot,
SortOrder: b.SortOrder,
})
}
req := &abiv1.ProvisionerEnsurePageRequest{Page: seed}
return s.invoke(context.Background(), "ensure_page", req, &abiv1.ProvisionerEnsurePageResponse{})
}
func (s *provisionerStub) OverrideSiteSettings(overrides map[string]any) error {
overridesJSON, err := json.Marshal(overrides)
if err != nil {
return fmt.Errorf("provisioner.override_site_settings: marshal overrides: %w", err)
}
req := &abiv1.ProvisionerOverrideSiteSettingsRequest{OverridesJson: overridesJSON}
return s.invoke(context.Background(), "override_site_settings", req, &abiv1.ProvisionerOverrideSiteSettingsResponse{})
}
func (s *provisionerStub) EnsureMenuItem(menuName string, config plugin.MenuItemConfig) error {
req := &abiv1.ProvisionerEnsureMenuItemRequest{
MenuName: menuName,
Label: config.Label,
Url: config.URL,
PageSlug: config.PageSlug,
SortOrder: config.SortOrder,
}
return s.invoke(context.Background(), "ensure_menu_item", req, &abiv1.ProvisionerEnsureMenuItemResponse{})
}
func (s *provisionerStub) RegisterEmbeddingConfig(config plugin.EmbeddingConfigDef) error {
req := &abiv1.ProvisionerRegisterEmbeddingConfigRequest{
TableKey: config.TableKey,
TextTemplate: config.TextTemplate,
Enabled: config.Enabled,
}
return s.invoke(context.Background(), "register_embedding_config", req, &abiv1.ProvisionerRegisterEmbeddingConfigResponse{})
}
// EnsureEmbed crosses the template-rendered surface only: EmbedConfig.
// RenderFunc is a function value that cannot serialize, so an ABI-provisioned
// embed must carry a Template (a RenderFunc-only embed returns an error
// rather than silently dropping the renderer).
func (s *provisionerStub) EnsureEmbed(config plugin.EmbedConfig) error {
if config.RenderFunc != nil && config.Template == "" {
return fmt.Errorf("provisioner.ensure_embed: embed %q uses RenderFunc, which cannot cross the wasm ABI — provide a Template instead", config.Key)
}
req := &abiv1.ProvisionerEnsureEmbedRequest{
Key: config.Key,
Title: config.Title,
Description: config.Description,
Icon: config.Icon,
LabelField: config.LabelField,
Template: config.Template,
DataSourceType: config.DataSource.Type,
DataSourceTableKey: config.DataSource.TableKey,
}
return s.invoke(context.Background(), "ensure_embed", req, &abiv1.ProvisionerEnsureEmbedResponse{})
}
func (s *provisionerStub) EnsureJobSchedule(config plugin.JobScheduleConfig) error {
req := &abiv1.ProvisionerEnsureJobScheduleRequest{
JobType: config.JobType,
CronExpression: config.CronExpression,
ConfigJson: config.Config,
}
return s.invoke(context.Background(), "ensure_job_schedule", req, &abiv1.ProvisionerEnsureJobScheduleResponse{})
}
func (s *provisionerStub) UpdateDataTableRowField(ctx context.Context, rowID uuid.UUID, fieldKey string, value any) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("provisioner.update_data_table_row_field: marshal value: %w", err)
}
req := &abiv1.ProvisionerUpdateDataTableRowFieldRequest{
RowId: rowID.String(),
FieldKey: fieldKey,
ValueJson: valueJSON,
}
return s.invoke(ctx, "update_data_table_row_field", req, &abiv1.ProvisionerUpdateDataTableRowFieldResponse{})
}
func (s *provisionerStub) DisableOrphanedJobSchedules(registeredTypes []string) error {
req := &abiv1.ProvisionerDisableOrphanedJobSchedulesRequest{RegisteredTypes: registeredTypes}
return s.invoke(context.Background(), "disable_orphaned_job_schedules", req, &abiv1.ProvisionerDisableOrphanedJobSchedulesResponse{})
}
func (s *provisionerStub) EnsurePlugin(name string) error {
req := &abiv1.ProvisionerEnsurePluginRequest{Name: name}
return s.invoke(context.Background(), "ensure_plugin", req, &abiv1.ProvisionerEnsurePluginResponse{})
}
func (s *provisionerStub) EnsureCustomColor(config plugin.CustomColorConfig) error {
req := &abiv1.ProvisionerEnsureCustomColorRequest{
Name: config.Name,
LightValue: config.LightValue,
DarkValue: config.DarkValue,
Source: config.Source,
}
return s.invoke(context.Background(), "ensure_custom_color", req, &abiv1.ProvisionerEnsureCustomColorResponse{})
}
func (s *provisionerStub) EnsureMedia(deposit plugin.MediaDeposit) error {
if deposit.ID == uuid.Nil {
return fmt.Errorf("provisioner.ensure_media: MediaDeposit.ID is required (the deterministic, template-referable key)")
}
req := &abiv1.ProvisionerEnsureMediaRequest{
Id: deposit.ID.String(),
Filename: deposit.Filename,
Data: deposit.Data,
AltText: deposit.AltText,
Folder: deposit.Folder,
Source: deposit.Source,
}
return s.invoke(context.Background(), "ensure_media", req, &abiv1.ProvisionerEnsureMediaResponse{})
}

View File

@ -0,0 +1,78 @@
package caps
import (
"context"
"sort"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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,53 @@
package caps
import (
"context"
"encoding/json"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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{})
}
func (s *settingsStub) UpdatePluginSettings(ctx context.Context, pluginName string, settingsMap map[string]any) error {
settingsJSON, err := json.Marshal(settingsMap)
if err != nil {
return err
}
req := &abiv1.SettingsUpdatePluginSettingsRequest{PluginName: pluginName, SettingsJson: settingsJSON}
return s.invoke(ctx, "update_plugin_settings", req, &abiv1.SettingsUpdatePluginSettingsResponse{})
}

View File

@ -0,0 +1,91 @@
package caps
import (
"context"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/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

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