feat(abi): wasm plugin ABI protobuf schema v1 (WO-WZ-001)

Defines the host<->guest wire contract for the .so -> wazero migration as a
repo-local buf module (abi/proto/v1, generated Go in abi/v1):

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-03 12:47:00 +08:00
parent c6305d18c2
commit 704b046727
16 changed files with 12546 additions and 0 deletions

View File

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

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

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

21
abi/buf.yaml Normal file
View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,197 @@
# Wasm Plugin ABI (v1)
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
Design rationale: the wasm plugin migration design spec in the cms repo
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
The `abi/` buf module is deliberately separate from the repo-root buf config:
`proto/` is the shared block/proto git submodule (service API contracts),
while this ABI is SDK-internal and versions in lockstep with the guest shim,
so it lives repo-local.
## Versioning — `abi_version`
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
**major** version the plugin was built against. Current value: **1**.
- The host **rejects** any manifest whose major version it does not support —
at install/publish time (manifest read) and again at `DESCRIBE`
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
newer guest shim can refuse an older host symmetrically).
- Within a major version, evolution is protobuf-additive only: new fields,
new `Hook` values, new capability methods. Removing or renaming anything
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
in `abi/buf.yaml`) enforces this against the previous commit.
## Calling convention (ptr+len, packed u64)
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
protobuf bytes in guest linear memory. The guest exports (via
`go:wasmexport`):
| Export | Signature | Purpose |
|---|---|---|
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
Host functions (guest→host capability calls, module `env`) use the same
shape in reverse: the guest passes `(ptr, len)` framing a serialized
`HostCallRequest`; the host returns a packed `u64` framing a
`HostCallResponse` that it wrote into guest memory via `bn_alloc`.
A `packed` value of `0` means the callee could not even produce an envelope
(allocation failure / trap); the caller treats it as
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
Instances are single-threaded: one `bn_invoke` at a time per instance;
concurrency comes from the per-plugin instance pool.
## Hook catalog (`invoke.proto`)
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}`
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
| Hook | Request / Response | Fires |
|---|---|---|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
## Capability calls (`capability.proto`, `db.proto`)
Guest→host. Envelope: `HostCallRequest{method, payload}`
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
each pair mirrors one Go interface method from `CoreServices` 1:1
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
| Family | Methods | Go surface |
|---|---|---|
| `content` | `get_author_profile`, `get_page`, `get_post`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | `content.Content` |
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting` | `settings.Settings`, `settings.Updater` |
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
| `media` | `deposit` | `plugin.Media` |
| `email` | `send` | `plugin.EmailSender` |
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
| `bridge` | `register_service`, `get_service` | `plugin.PluginBridge` |
| `jobs` | `submit` | `plugin.JobRunner` |
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
The guest half of the SDK implements the existing Go interfaces as stubs
marshaling to these calls, so plugin code compiles unchanged.
`CoreServices` members that do **not** cross as capability calls:
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
chain's deadline so a guest can never pin a connection.
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
`manifest.rbac_method_roles`.
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
declaration; the host constructs and mounts the handlers.
- `RAGService.RegisterContentFetcher` → static
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
callback inversion.
## Error semantics
`AbiError{code, message}` travels in `InvokeResponse.error` and
`HostCallResponse.error`:
| Code | Meaning | Instance consequence |
|---|---|---|
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
Host-function errors surface to plugin code as ordinary Go errors via the
guest SDK. Repeated instance failures trip the existing
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
sqlc/pgx error handling keeps working.
## Manifest ↔ `PluginRegistration` mapping
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
Field-by-field:
| `PluginRegistration` field | Wire counterpart |
|---|---|
| `Name` | `PluginManifest.name` |
| `Version` | `PluginManifest.version` |
| `Dependencies` | `dependencies` (`Dependency`) |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
| `SettingsSchema` | `settings_schema` (JSON bytes) |
| `ThemePresets` | `theme_presets` (JSON bytes) |
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
| `SettingsPanel` | `settings_panel` |
| `AdminPages` | `admin_pages` (`AdminPage`) |
| `CSSManifest` | `css_manifest` (`CssManifest`) |
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
| `JobHandlers` | `job_types` + `HOOK_JOB` |
| `AIActions` | `ai_actions` (`AiAction`) |
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
| `Load` | `has_load_hook` + `HOOK_LOAD` |
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
| `RequiredIconPacks` | `required_icon_packs` |
## Render context
`RenderContext` (`render.proto`) is the explicit envelope of every value
blocks read from `ctx` today (`core/blocks/context.go`): request info,
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
category/master-page, requested path, injected/expected slots, editor flag,
block + page IDs, human-proof banner, detail row, theme variables JSON, and
locale.
Function-valued context entries cannot serialize; their v1 mapping:
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
- `SlotRenderer` / `MediaResolver` / `EmbedResolver`**open items** (below).
## Open items (flagged for runtime WOs — additive, no major bump needed)
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing
its guest-side `Handler` needs a host→guest hook (e.g. `HOOK_AI_TOOL_CALL`).
Deliberately not added here — the WO fixes the v1 hook catalog.
- **Job progress**: `plugin.JobHandlerFunc`'s `progress(current, total,
message)` callback needs a `jobs.progress` host function.
- **Bridge calls**: `bridge.register_service`/`get_service` cover
registration/lookup only; typed cross-plugin *invocation* (today: shared
in-process Go values) needs a runtime design.
- **Directory extension callbacks**: `panel_section_count` /
`pin_decorator_count` declare the guest callbacks; invoking them needs a
hook.
- **Slot/media/embed resolvers in render**: container-slot rendering and
media/embed resolution during a guest render need host functions (or host-
side pre-rendering into `RenderContext`).