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:
parent
807f713f3d
commit
b6d40ed8ac
40
AGENTS.md
Normal file
40
AGENTS.md
Normal 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`.
|
||||||
21
Makefile
Normal file
21
Makefile
Normal 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
|
||||||
47
README.md
47
README.md
@ -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
12
abi/buf.gen.yaml
Normal 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
21
abi/buf.yaml
Normal 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
|
||||||
844
abi/proto/v1/capability.proto
Normal file
844
abi/proto/v1/capability.proto
Normal file
@ -0,0 +1,844 @@
|
|||||||
|
// capability.proto — guest→host 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 → host→guest 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 guest→host 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 host→guest 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
119
abi/proto/v1/db.proto
Normal 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
37
abi/proto/v1/http.proto
Normal 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
278
abi/proto/v1/invoke.proto
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
// invoke.proto — the bn_invoke envelope, hook catalog, and the job/lifecycle
|
||||||
|
// hook payloads (WO-WZ-001).
|
||||||
|
//
|
||||||
|
// Every host→guest 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 host→guest 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 guest→host 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
243
abi/proto/v1/manifest.proto
Normal 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
259
abi/proto/v1/render.proto
Normal 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
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
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
296
abi/v1/http.pb.go
Normal 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
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
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
2006
abi/v1/render.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
28
ai/tools.go
Normal file
28
ai/tools.go
Normal 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
18
auth/claims.go
Normal 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
34
auth/context.go
Normal 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
25
auth/public_users.go
Normal 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
91
auth/trustedheaders.go
Normal 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)))
|
||||||
|
})
|
||||||
|
}
|
||||||
82
auth/trustedheaders_test.go
Normal file
82
auth/trustedheaders_test.go
Normal 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
299
blocks/builtin/html.go
Normal 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, "&", "&")
|
||||||
|
url = strings.ReplaceAll(url, """, "\"")
|
||||||
|
url = strings.ReplaceAll(url, "'", "'")
|
||||||
|
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
469
blocks/context.go
Normal 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
62
blocks/powered.go
Normal 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
59
blocks/powered_test.go
Normal 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
12
blocks/registry.go
Normal 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
250
blocks/shared/button.go
Normal 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 != ""
|
||||||
|
}
|
||||||
54
blocks/shared/button.templ
Normal file
54
blocks/shared/button.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
370
blocks/shared/button_templ.go
Normal file
370
blocks/shared/button_templ.go
Normal 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
99
blocks/tagfilter.go
Normal 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
1
blocks/tags/doc.go
Normal file
@ -0,0 +1 @@
|
|||||||
|
package tags
|
||||||
74
blocks/template.go
Normal file
74
blocks/template.go
Normal 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
106
blocks/types.go
Normal 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 · %[2]s keystrokes · %[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
86
content/author.go
Normal 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
68
content/content.go
Normal 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
7
crypto/crypto.go
Normal 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)
|
||||||
|
}
|
||||||
21
datasources/datasources.go
Normal file
21
datasources/datasources.go
Normal 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
45
gating/gating.go
Normal 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
22
go.mod
Normal 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
46
go.sum
Normal 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
34
menus/menus.go
Normal 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
43
plugin/block_registry.go
Normal 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)
|
||||||
|
}
|
||||||
70
plugin/block_registry_test.go
Normal file
70
plugin/block_registry_test.go
Normal 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
42
plugin/bridge.go
Normal 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
13
plugin/community.go
Normal 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
44
plugin/css_manifest.go
Normal 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
147
plugin/deps.go
Normal 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
150
plugin/mod.go
Normal 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
434
plugin/mod_test.go
Normal 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
112
plugin/provisioner.go
Normal 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
54
plugin/registration.go
Normal 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
23
plugin/reviews.go
Normal 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
105
plugin/seeder.go
Normal 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
61
plugin/service.go
Normal 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
64
plugin/topo_sort.go
Normal 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
121
plugin/types.go
Normal 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
116
plugin/version.go
Normal 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
77
plugin/version_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
324
plugin/wasmguest/bnwasm/dbvalue.go
Normal file
324
plugin/wasmguest/bnwasm/dbvalue.go
Normal 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()
|
||||||
|
}
|
||||||
203
plugin/wasmguest/bnwasm/dbvalue_fixtures.go
Normal file
203
plugin/wasmguest/bnwasm/dbvalue_fixtures.go
Normal 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",""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
119
plugin/wasmguest/bnwasm/dbvalue_test.go
Normal file
119
plugin/wasmguest/bnwasm/dbvalue_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
201
plugin/wasmguest/bnwasm/driver.go
Normal file
201
plugin/wasmguest/bnwasm/driver.go
Normal 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
|
||||||
|
}
|
||||||
300
plugin/wasmguest/bnwasm/driver_test.go
Normal file
300
plugin/wasmguest/bnwasm/driver_test.go
Normal 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)
|
||||||
|
}
|
||||||
60
plugin/wasmguest/bnwasm/pool.go
Normal file
60
plugin/wasmguest/bnwasm/pool.go
Normal 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}
|
||||||
|
}
|
||||||
120
plugin/wasmguest/bnwasm/rows.go
Normal file
120
plugin/wasmguest/bnwasm/rows.go
Normal 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
|
||||||
|
}
|
||||||
207
plugin/wasmguest/bnwasm/sqlcgen_test.go
Normal file
207
plugin/wasmguest/bnwasm/sqlcgen_test.go
Normal 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: ¬es})
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
194
plugin/wasmguest/bnwasm/transport.go
Normal file
194
plugin/wasmguest/bnwasm/transport.go
Normal 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()
|
||||||
|
}
|
||||||
102
plugin/wasmguest/bnwasm/tx.go
Normal file
102
plugin/wasmguest/bnwasm/tx.go
Normal 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 }
|
||||||
74
plugin/wasmguest/bnwasm/tx_expired_test.go
Normal file
74
plugin/wasmguest/bnwasm/tx_expired_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
plugin/wasmguest/caps/ai.go
Normal file
69
plugin/wasmguest/caps/ai.go
Normal 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
|
||||||
|
}
|
||||||
120
plugin/wasmguest/caps/author.go
Normal file
120
plugin/wasmguest/caps/author.go
Normal 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
|
||||||
|
}
|
||||||
20
plugin/wasmguest/caps/badges.go
Normal file
20
plugin/wasmguest/caps/badges.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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{})
|
||||||
|
}
|
||||||
74
plugin/wasmguest/caps/bridge.go
Normal file
74
plugin/wasmguest/caps/bridge.go
Normal 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
|
||||||
|
}
|
||||||
89
plugin/wasmguest/caps/caps.go
Normal file
89
plugin/wasmguest/caps/caps.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// Package caps implements every CoreServices capability interface
|
||||||
|
// (core/plugin/deps.go) as a guest-side stub that marshals to the WO-WZ-001
|
||||||
|
// capability messages (abiv1) and dispatches them through a single generic
|
||||||
|
// guest→host transport. Plugin code keeps compiling and calling against
|
||||||
|
// content.Content, settings.Settings, plugin.PluginBridge, etc. — unchanged —
|
||||||
|
// while the concrete work now happens host-side over the wasm ABI.
|
||||||
|
//
|
||||||
|
// # The transport seam
|
||||||
|
//
|
||||||
|
// A capability call is "marshal a family request, invoke
|
||||||
|
// '<family>.<method>', unmarshal the reply". That transport is injected as a
|
||||||
|
// CallFunc so the marshaling logic in this package stays natively testable
|
||||||
|
// (a fake CallFunc in caps_roundtrip_test.go), while the wasm guest shim
|
||||||
|
// (package wasmguest, wasip1) binds the real host_call-backed transport. This
|
||||||
|
// package deliberately does NOT import wasmguest: wasmguest imports caps (to
|
||||||
|
// assemble the services and reach the guest-side RAG fetcher registry), so the
|
||||||
|
// dependency only points one way.
|
||||||
|
//
|
||||||
|
// # Method disposition
|
||||||
|
//
|
||||||
|
// Every CoreServices member is either a stub here or documented host-side in
|
||||||
|
// core/docs/wasm-abi.md §"Capability calls". Host-side members (no stub):
|
||||||
|
// Pool (db.* driver), Interceptors (host connect options), AppURL/MediaPath
|
||||||
|
// (delivered in LoadRequest.host_config), and CoreServiceBindings (static
|
||||||
|
// manifest.core_service_bindings the host mounts). RAGService.
|
||||||
|
// RegisterContentFetcher is guest-side (records fetchers for HOOK_RAG_FETCH);
|
||||||
|
// its Query/OnContentChanged marshal out.
|
||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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)
|
||||||
|
}
|
||||||
957
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal file
957
plugin/wasmguest/caps/caps_roundtrip_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
141
plugin/wasmguest/caps/content.go
Normal file
141
plugin/wasmguest/caps/content.go
Normal 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()
|
||||||
|
}
|
||||||
51
plugin/wasmguest/caps/coreservices.go
Normal file
51
plugin/wasmguest/caps/coreservices.go
Normal 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}},
|
||||||
|
}
|
||||||
|
}
|
||||||
30
plugin/wasmguest/caps/crypto.go
Normal file
30
plugin/wasmguest/caps/crypto.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
46
plugin/wasmguest/caps/datasources.go
Normal file
46
plugin/wasmguest/caps/datasources.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
19
plugin/wasmguest/caps/email.go
Normal file
19
plugin/wasmguest/caps/email.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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{})
|
||||||
|
}
|
||||||
46
plugin/wasmguest/caps/embeddings.go
Normal file
46
plugin/wasmguest/caps/embeddings.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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()
|
||||||
|
}
|
||||||
51
plugin/wasmguest/caps/gating.go
Normal file
51
plugin/wasmguest/caps/gating.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
18
plugin/wasmguest/caps/jobs.go
Normal file
18
plugin/wasmguest/caps/jobs.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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{})
|
||||||
|
}
|
||||||
36
plugin/wasmguest/caps/media.go
Normal file
36
plugin/wasmguest/caps/media.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
55
plugin/wasmguest/caps/menus.go
Normal file
55
plugin/wasmguest/caps/menus.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
189
plugin/wasmguest/caps/provisioner.go
Normal file
189
plugin/wasmguest/caps/provisioner.go
Normal 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{})
|
||||||
|
}
|
||||||
78
plugin/wasmguest/caps/rag.go
Normal file
78
plugin/wasmguest/caps/rag.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
35
plugin/wasmguest/caps/reviews.go
Normal file
35
plugin/wasmguest/caps/reviews.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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
|
||||||
|
}
|
||||||
53
plugin/wasmguest/caps/settings.go
Normal file
53
plugin/wasmguest/caps/settings.go
Normal 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{})
|
||||||
|
}
|
||||||
91
plugin/wasmguest/caps/subscriptions.go
Normal file
91
plugin/wasmguest/caps/subscriptions.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package caps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
abiv1 "git.dev.alexdunmow.com/block/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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
summarizesysuser
|
||||||
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_text_call_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
generated
|
||||||
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/ai_tools_register_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
lookupLookupd"{"type":"object"}
|
||||||
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/ai_tools_register_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||||
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/badges_refresh_badges_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/bridge_get_service_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
symposiumsearch
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user