feat(abi): injection-complete capability surface — provisioner family, content authoring, 4 callback hooks (WO-WZ-019)

Dynamic families added to the ABI + guest SDK:
- provisioner.* (14 methods, 1:1 plugin.Provisioner): wasm provisioning was
  silently dead (RegisterWithProvisioner got a noopProvisioner and the cms
  loader ignored has_provisioner). Now a LOAD-TIME capability via the new
  CoreServices.Provisioner field; EnsureEmbed rejects RenderFunc-only embeds.
- content.* writes (content.Author + CoreServices.ContentAuthor):
  create_page, set_page_blocks, publish_page, set_page_seo, upsert_post.
- settings.update_plugin_settings (settings.Updater grows the method).
- bridge.invoke + plugin.BridgeInvokable: opaque-payload cross-plugin calls
  (typed GetService still returns nil across the sandbox by design).
- jobs.progress: HOOK_JOB handlers' progress() now crosses (was discarded).

New host→guest hooks: HOOK_AI_TOOL_CALL (executes recorded ai.ToolDefinition
handlers — registers tools in Register so every pooled instance has them),
HOOK_BRIDGE_CALL, HOOK_DIRECTORY_PANEL_SECTION, HOOK_DIRECTORY_PIN_DECORATOR.
The ai/bridge stubs now record handlers/values locally in addition to
forwarding names.

buf breaking clean (additive within ABI major 1); golden round-trips added
for the new families (cms host replays the same goldens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 10:02:21 +08:00
parent 6c52e3077c
commit 9e39119555
38 changed files with 4552 additions and 127 deletions

View File

@ -556,3 +556,289 @@ message BadgesRefreshBadgesRequest {
}
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;
}

View File

@ -49,6 +49,18 @@ enum Hook {
// Apply a plugin-declared template filter (manifest.declared_filters):
// payload = ApplyFilterRequest / ApplyFilterResponse.
HOOK_APPLY_FILTER = 11;
// Execute a guest-registered AI tool handler (ai.tools.register):
// payload = AiToolCallRequest / AiToolCallResponse.
HOOK_AI_TOOL_CALL = 12;
// Invoke a method on a bridge service this plugin registered
// (bridge.register_service): payload = BridgeCallRequest / BridgeCallResponse.
HOOK_BRIDGE_CALL = 13;
// Render one directory panel section (DirectoryExtensions.PanelSections):
// payload = DirectoryPanelSectionRequest / DirectoryPanelSectionResponse.
HOOK_DIRECTORY_PANEL_SECTION = 14;
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
HOOK_DIRECTORY_PIN_DECORATOR = 15;
}
// InvokeRequest is the hostguest call envelope.
@ -188,6 +200,70 @@ message MediaAnalyzedEvent {
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;

File diff suppressed because it is too large Load Diff

View File

@ -66,6 +66,18 @@ const (
// Apply a plugin-declared template filter (manifest.declared_filters):
// payload = ApplyFilterRequest / ApplyFilterResponse.
Hook_HOOK_APPLY_FILTER Hook = 11
// Execute a guest-registered AI tool handler (ai.tools.register):
// payload = AiToolCallRequest / AiToolCallResponse.
Hook_HOOK_AI_TOOL_CALL Hook = 12
// Invoke a method on a bridge service this plugin registered
// (bridge.register_service): payload = BridgeCallRequest / BridgeCallResponse.
Hook_HOOK_BRIDGE_CALL Hook = 13
// Render one directory panel section (DirectoryExtensions.PanelSections):
// payload = DirectoryPanelSectionRequest / DirectoryPanelSectionResponse.
Hook_HOOK_DIRECTORY_PANEL_SECTION Hook = 14
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
Hook_HOOK_DIRECTORY_PIN_DECORATOR Hook = 15
)
// Enum value maps for Hook.
@ -83,20 +95,28 @@ var (
9: "HOOK_DESCRIBE",
10: "HOOK_RENDER_TAG",
11: "HOOK_APPLY_FILTER",
12: "HOOK_AI_TOOL_CALL",
13: "HOOK_BRIDGE_CALL",
14: "HOOK_DIRECTORY_PANEL_SECTION",
15: "HOOK_DIRECTORY_PIN_DECORATOR",
}
Hook_value = map[string]int32{
"HOOK_UNSPECIFIED": 0,
"HOOK_RENDER_BLOCK": 1,
"HOOK_RENDER_TEMPLATE": 2,
"HOOK_HANDLE_HTTP": 3,
"HOOK_JOB": 4,
"HOOK_LOAD": 5,
"HOOK_UNLOAD": 6,
"HOOK_RAG_FETCH": 7,
"HOOK_MEDIA_HOOK": 8,
"HOOK_DESCRIBE": 9,
"HOOK_RENDER_TAG": 10,
"HOOK_APPLY_FILTER": 11,
"HOOK_UNSPECIFIED": 0,
"HOOK_RENDER_BLOCK": 1,
"HOOK_RENDER_TEMPLATE": 2,
"HOOK_HANDLE_HTTP": 3,
"HOOK_JOB": 4,
"HOOK_LOAD": 5,
"HOOK_UNLOAD": 6,
"HOOK_RAG_FETCH": 7,
"HOOK_MEDIA_HOOK": 8,
"HOOK_DESCRIBE": 9,
"HOOK_RENDER_TAG": 10,
"HOOK_APPLY_FILTER": 11,
"HOOK_AI_TOOL_CALL": 12,
"HOOK_BRIDGE_CALL": 13,
"HOOK_DIRECTORY_PANEL_SECTION": 14,
"HOOK_DIRECTORY_PIN_DECORATOR": 15,
}
)
@ -1115,6 +1135,433 @@ func (x *MediaAnalyzedEvent) GetSafeRacy() string {
return ""
}
// AiToolCallRequest executes the guest-side Handler of a tool the plugin
// registered via ai.tools.register (ai.ToolHandler).
type AiToolCallRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
// JSON encoding of the params map.
ParamsJson []byte `protobuf:"bytes,2,opt,name=params_json,json=paramsJson,proto3" json:"params_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AiToolCallRequest) Reset() {
*x = AiToolCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AiToolCallRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AiToolCallRequest) ProtoMessage() {}
func (x *AiToolCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[17]
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 AiToolCallRequest.ProtoReflect.Descriptor instead.
func (*AiToolCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
}
func (x *AiToolCallRequest) GetSlug() string {
if x != nil {
return x.Slug
}
return ""
}
func (x *AiToolCallRequest) GetParamsJson() []byte {
if x != nil {
return x.ParamsJson
}
return nil
}
// 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.
type AiToolCallResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"`
ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AiToolCallResponse) Reset() {
*x = AiToolCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AiToolCallResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AiToolCallResponse) ProtoMessage() {}
func (x *AiToolCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[18]
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 AiToolCallResponse.ProtoReflect.Descriptor instead.
func (*AiToolCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{18}
}
func (x *AiToolCallResponse) GetContent() string {
if x != nil {
return x.Content
}
return ""
}
func (x *AiToolCallResponse) GetErrorMessage() string {
if x != nil {
return x.ErrorMessage
}
return ""
}
// 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).
type BridgeCallRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BridgeCallRequest) Reset() {
*x = BridgeCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BridgeCallRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BridgeCallRequest) ProtoMessage() {}
func (x *BridgeCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[19]
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 BridgeCallRequest.ProtoReflect.Descriptor instead.
func (*BridgeCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{19}
}
func (x *BridgeCallRequest) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *BridgeCallRequest) GetMethod() string {
if x != nil {
return x.Method
}
return ""
}
func (x *BridgeCallRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
type BridgeCallResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BridgeCallResponse) Reset() {
*x = BridgeCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BridgeCallResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BridgeCallResponse) ProtoMessage() {}
func (x *BridgeCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[20]
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 BridgeCallResponse.ProtoReflect.Descriptor instead.
func (*BridgeCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{20}
}
func (x *BridgeCallResponse) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
// DirectoryPanelSectionRequest renders the index-th registered panel section
// (DirectoryExtensions.PanelSections[index], counted in
// manifest.directory_extensions.panel_section_count).
type DirectoryPanelSectionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Index uint32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"`
// JSON encoding of the row data map.
DataJson []byte `protobuf:"bytes,2,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DirectoryPanelSectionRequest) Reset() {
*x = DirectoryPanelSectionRequest{}
mi := &file_v1_invoke_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DirectoryPanelSectionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DirectoryPanelSectionRequest) ProtoMessage() {}
func (x *DirectoryPanelSectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[21]
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 DirectoryPanelSectionRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{21}
}
func (x *DirectoryPanelSectionRequest) GetIndex() uint32 {
if x != nil {
return x.Index
}
return 0
}
func (x *DirectoryPanelSectionRequest) GetDataJson() []byte {
if x != nil {
return x.DataJson
}
return nil
}
type DirectoryPanelSectionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Html string `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DirectoryPanelSectionResponse) Reset() {
*x = DirectoryPanelSectionResponse{}
mi := &file_v1_invoke_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DirectoryPanelSectionResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DirectoryPanelSectionResponse) ProtoMessage() {}
func (x *DirectoryPanelSectionResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[22]
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 DirectoryPanelSectionResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{22}
}
func (x *DirectoryPanelSectionResponse) GetHtml() string {
if x != nil {
return x.Html
}
return ""
}
// DirectoryPinDecoratorRequest runs the index-th registered pin decorator,
// which mutates the pin map in place; the mutated pin is returned.
type DirectoryPinDecoratorRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Index uint32 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"`
// JSON encoding of the pin map (mutated by the decorator).
PinJson []byte `protobuf:"bytes,2,opt,name=pin_json,json=pinJson,proto3" json:"pin_json,omitempty"`
// JSON encoding of the row data map (read-only input).
DataJson []byte `protobuf:"bytes,3,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DirectoryPinDecoratorRequest) Reset() {
*x = DirectoryPinDecoratorRequest{}
mi := &file_v1_invoke_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DirectoryPinDecoratorRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DirectoryPinDecoratorRequest) ProtoMessage() {}
func (x *DirectoryPinDecoratorRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[23]
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 DirectoryPinDecoratorRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{23}
}
func (x *DirectoryPinDecoratorRequest) GetIndex() uint32 {
if x != nil {
return x.Index
}
return 0
}
func (x *DirectoryPinDecoratorRequest) GetPinJson() []byte {
if x != nil {
return x.PinJson
}
return nil
}
func (x *DirectoryPinDecoratorRequest) GetDataJson() []byte {
if x != nil {
return x.DataJson
}
return nil
}
type DirectoryPinDecoratorResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// JSON encoding of the mutated pin map.
PinJson []byte `protobuf:"bytes,1,opt,name=pin_json,json=pinJson,proto3" json:"pin_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DirectoryPinDecoratorResponse) Reset() {
*x = DirectoryPinDecoratorResponse{}
mi := &file_v1_invoke_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DirectoryPinDecoratorResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DirectoryPinDecoratorResponse) ProtoMessage() {}
func (x *DirectoryPinDecoratorResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[24]
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 DirectoryPinDecoratorResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{24}
}
func (x *DirectoryPinDecoratorResponse) GetPinJson() []byte {
if x != nil {
return x.PinJson
}
return nil
}
// ModerationDecisionEvent mirrors plugin.ModerationDecisionEvent.
type ModerationDecisionEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1133,7 +1580,7 @@ type ModerationDecisionEvent struct {
func (x *ModerationDecisionEvent) Reset() {
*x = ModerationDecisionEvent{}
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1145,7 +1592,7 @@ func (x *ModerationDecisionEvent) String() string {
func (*ModerationDecisionEvent) ProtoMessage() {}
func (x *ModerationDecisionEvent) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1158,7 +1605,7 @@ func (x *ModerationDecisionEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ModerationDecisionEvent.ProtoReflect.Descriptor instead.
func (*ModerationDecisionEvent) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
return file_v1_invoke_proto_rawDescGZIP(), []int{25}
}
func (x *ModerationDecisionEvent) GetMediaId() string {
@ -1289,7 +1736,31 @@ const file_v1_invoke_proto_rawDesc = "" +
"safe_adult\x18\b \x01(\tR\tsafeAdult\x12#\n" +
"\rsafe_violence\x18\t \x01(\tR\fsafeViolence\x12\x1b\n" +
"\tsafe_racy\x18\n" +
" \x01(\tR\bsafeRacy\"\xb7\x02\n" +
" \x01(\tR\bsafeRacy\"H\n" +
"\x11AiToolCallRequest\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\x12\x1f\n" +
"\vparams_json\x18\x02 \x01(\fR\n" +
"paramsJson\"S\n" +
"\x12AiToolCallResponse\x12\x18\n" +
"\acontent\x18\x01 \x01(\tR\acontent\x12#\n" +
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"h\n" +
"\x11BridgeCallRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" +
"\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\".\n" +
"\x12BridgeCallResponse\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
"\x1cDirectoryPanelSectionRequest\x12\x14\n" +
"\x05index\x18\x01 \x01(\rR\x05index\x12\x1b\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\"3\n" +
"\x1dDirectoryPanelSectionResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\tR\x04html\"l\n" +
"\x1cDirectoryPinDecoratorRequest\x12\x14\n" +
"\x05index\x18\x01 \x01(\rR\x05index\x12\x19\n" +
"\bpin_json\x18\x02 \x01(\fR\apinJson\x12\x1b\n" +
"\tdata_json\x18\x03 \x01(\fR\bdataJson\":\n" +
"\x1dDirectoryPinDecoratorResponse\x12\x19\n" +
"\bpin_json\x18\x01 \x01(\fR\apinJson\"\xb7\x02\n" +
"\x17ModerationDecisionEvent\x12\x19\n" +
"\bmedia_id\x18\x01 \x01(\tR\amediaId\x12\x1f\n" +
"\vanalysis_id\x18\x02 \x01(\tR\n" +
@ -1301,7 +1772,7 @@ const file_v1_invoke_proto_rawDesc = "" +
"sourceType\x12\"\n" +
"\rsource_ref_id\x18\a \x01(\tR\vsourceRefId\x12!\n" +
"\fmoderated_by\x18\b \x01(\tR\vmoderatedBy\x12\x12\n" +
"\x04note\x18\t \x01(\tR\x04note*\xf9\x01\n" +
"\x04note\x18\t \x01(\tR\x04note*\xea\x02\n" +
"\x04Hook\x12\x14\n" +
"\x10HOOK_UNSPECIFIED\x10\x00\x12\x15\n" +
"\x11HOOK_RENDER_BLOCK\x10\x01\x12\x18\n" +
@ -1315,7 +1786,11 @@ const file_v1_invoke_proto_rawDesc = "" +
"\rHOOK_DESCRIBE\x10\t\x12\x13\n" +
"\x0fHOOK_RENDER_TAG\x10\n" +
"\x12\x15\n" +
"\x11HOOK_APPLY_FILTER\x10\v*\xf3\x01\n" +
"\x11HOOK_APPLY_FILTER\x10\v\x12\x15\n" +
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" +
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\xf3\x01\n" +
"\fAbiErrorCode\x12\x1e\n" +
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
@ -1338,38 +1813,46 @@ func file_v1_invoke_proto_rawDescGZIP() []byte {
}
var file_v1_invoke_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_v1_invoke_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
var file_v1_invoke_proto_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_v1_invoke_proto_goTypes = []any{
(Hook)(0), // 0: abi.v1.Hook
(AbiErrorCode)(0), // 1: abi.v1.AbiErrorCode
(*InvokeRequest)(nil), // 2: abi.v1.InvokeRequest
(*InvokeResponse)(nil), // 3: abi.v1.InvokeResponse
(*AbiError)(nil), // 4: abi.v1.AbiError
(*DescribeRequest)(nil), // 5: abi.v1.DescribeRequest
(*DescribeResponse)(nil), // 6: abi.v1.DescribeResponse
(*LoadRequest)(nil), // 7: abi.v1.LoadRequest
(*HostConfig)(nil), // 8: abi.v1.HostConfig
(*LoadResponse)(nil), // 9: abi.v1.LoadResponse
(*UnloadRequest)(nil), // 10: abi.v1.UnloadRequest
(*UnloadResponse)(nil), // 11: abi.v1.UnloadResponse
(*JobRequest)(nil), // 12: abi.v1.JobRequest
(*JobResponse)(nil), // 13: abi.v1.JobResponse
(*RagFetchRequest)(nil), // 14: abi.v1.RagFetchRequest
(*RagFetchResponse)(nil), // 15: abi.v1.RagFetchResponse
(*MediaHookRequest)(nil), // 16: abi.v1.MediaHookRequest
(*MediaHookResponse)(nil), // 17: abi.v1.MediaHookResponse
(*MediaAnalyzedEvent)(nil), // 18: abi.v1.MediaAnalyzedEvent
(*ModerationDecisionEvent)(nil), // 19: abi.v1.ModerationDecisionEvent
(*PluginManifest)(nil), // 20: abi.v1.PluginManifest
(Hook)(0), // 0: abi.v1.Hook
(AbiErrorCode)(0), // 1: abi.v1.AbiErrorCode
(*InvokeRequest)(nil), // 2: abi.v1.InvokeRequest
(*InvokeResponse)(nil), // 3: abi.v1.InvokeResponse
(*AbiError)(nil), // 4: abi.v1.AbiError
(*DescribeRequest)(nil), // 5: abi.v1.DescribeRequest
(*DescribeResponse)(nil), // 6: abi.v1.DescribeResponse
(*LoadRequest)(nil), // 7: abi.v1.LoadRequest
(*HostConfig)(nil), // 8: abi.v1.HostConfig
(*LoadResponse)(nil), // 9: abi.v1.LoadResponse
(*UnloadRequest)(nil), // 10: abi.v1.UnloadRequest
(*UnloadResponse)(nil), // 11: abi.v1.UnloadResponse
(*JobRequest)(nil), // 12: abi.v1.JobRequest
(*JobResponse)(nil), // 13: abi.v1.JobResponse
(*RagFetchRequest)(nil), // 14: abi.v1.RagFetchRequest
(*RagFetchResponse)(nil), // 15: abi.v1.RagFetchResponse
(*MediaHookRequest)(nil), // 16: abi.v1.MediaHookRequest
(*MediaHookResponse)(nil), // 17: abi.v1.MediaHookResponse
(*MediaAnalyzedEvent)(nil), // 18: abi.v1.MediaAnalyzedEvent
(*AiToolCallRequest)(nil), // 19: abi.v1.AiToolCallRequest
(*AiToolCallResponse)(nil), // 20: abi.v1.AiToolCallResponse
(*BridgeCallRequest)(nil), // 21: abi.v1.BridgeCallRequest
(*BridgeCallResponse)(nil), // 22: abi.v1.BridgeCallResponse
(*DirectoryPanelSectionRequest)(nil), // 23: abi.v1.DirectoryPanelSectionRequest
(*DirectoryPanelSectionResponse)(nil), // 24: abi.v1.DirectoryPanelSectionResponse
(*DirectoryPinDecoratorRequest)(nil), // 25: abi.v1.DirectoryPinDecoratorRequest
(*DirectoryPinDecoratorResponse)(nil), // 26: abi.v1.DirectoryPinDecoratorResponse
(*ModerationDecisionEvent)(nil), // 27: abi.v1.ModerationDecisionEvent
(*PluginManifest)(nil), // 28: abi.v1.PluginManifest
}
var file_v1_invoke_proto_depIdxs = []int32{
0, // 0: abi.v1.InvokeRequest.hook:type_name -> abi.v1.Hook
4, // 1: abi.v1.InvokeResponse.error:type_name -> abi.v1.AbiError
1, // 2: abi.v1.AbiError.code:type_name -> abi.v1.AbiErrorCode
20, // 3: abi.v1.DescribeResponse.manifest:type_name -> abi.v1.PluginManifest
28, // 3: abi.v1.DescribeResponse.manifest:type_name -> abi.v1.PluginManifest
8, // 4: abi.v1.LoadRequest.host_config:type_name -> abi.v1.HostConfig
18, // 5: abi.v1.MediaHookRequest.media_analyzed:type_name -> abi.v1.MediaAnalyzedEvent
19, // 6: abi.v1.MediaHookRequest.moderation_decision:type_name -> abi.v1.ModerationDecisionEvent
27, // 6: abi.v1.MediaHookRequest.moderation_decision:type_name -> abi.v1.ModerationDecisionEvent
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
@ -1393,7 +1876,7 @@ func file_v1_invoke_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_invoke_proto_rawDesc), len(file_v1_invoke_proto_rawDesc)),
NumEnums: 2,
NumMessages: 18,
NumMessages: 26,
NumExtensions: 0,
NumServices: 0,
},

86
content/author.go Normal file
View File

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

View File

@ -1,10 +1,30 @@
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.

View File

@ -20,6 +20,7 @@ import (
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
@ -61,6 +62,12 @@ type CoreServices struct {
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.

View File

@ -12,9 +12,15 @@ import (
// CoreServices.AITextCall func field (ai.text_call).
//
// ToolDefinition.Handler stays guest-side: registration marshals only the
// static descriptor. Host→guest tool execution is a runtime-WO concern
// flagged in core/docs/wasm-abi.md.
type aiStub struct{ base }
// 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)
@ -24,6 +30,10 @@ 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,
@ -37,6 +47,13 @@ func (s *aiStub) Register(tool *ai.ToolDefinition) {
_ = 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{

View File

@ -0,0 +1,120 @@
package caps
import (
"context"
"encoding/json"
"fmt"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/content"
"github.com/google/uuid"
)
// authorStub implements content.Author over the content.* write capability
// calls (WO-WZ-019). Same family as contentStub; split so the read surface
// stays untouched.
type authorStub struct{ base }
var _ content.Author = (*authorStub)(nil)
func (s *authorStub) CreatePage(ctx context.Context, params content.CreatePageParams) (content.CreatePageResult, error) {
req := &abiv1.ContentCreatePageRequest{
Slug: params.Slug,
ParentSlug: params.ParentSlug,
Title: params.Title,
TemplateKey: params.TemplateKey,
MasterPageKey: params.MasterPageKey,
}
resp := &abiv1.ContentCreatePageResponse{}
if err := s.invoke(ctx, "create_page", req, resp); err != nil {
return content.CreatePageResult{}, err
}
id, err := uuid.Parse(resp.GetPageId())
if err != nil {
return content.CreatePageResult{}, fmt.Errorf("content.create_page: bad page_id %q: %w", resp.GetPageId(), err)
}
return content.CreatePageResult{PageID: id, Created: resp.GetCreated()}, nil
}
func (s *authorStub) SetPageBlocks(ctx context.Context, pageID uuid.UUID, blocks []content.PageBlock) error {
req := &abiv1.ContentSetPageBlocksRequest{PageId: pageID.String()}
for _, b := range blocks {
pb, err := pageBlockToProto(b)
if err != nil {
return fmt.Errorf("content.set_page_blocks: block %q: %w", b.BlockKey, err)
}
req.Blocks = append(req.Blocks, pb)
}
return s.invoke(ctx, "set_page_blocks", req, &abiv1.ContentSetPageBlocksResponse{})
}
func (s *authorStub) PublishPage(ctx context.Context, pageID uuid.UUID) error {
req := &abiv1.ContentPublishPageRequest{PageId: pageID.String()}
return s.invoke(ctx, "publish_page", req, &abiv1.ContentPublishPageResponse{})
}
func (s *authorStub) SetPageSEO(ctx context.Context, pageID uuid.UUID, seo content.PageSEO) error {
req := &abiv1.ContentSetPageSeoRequest{
PageId: pageID.String(),
MetaTitle: seo.MetaTitle,
MetaDescription: seo.MetaDescription,
OgTitle: seo.OGTitle,
OgDescription: seo.OGDescription,
OgImage: seo.OGImage,
FocusKeyphrase: seo.FocusKeyphrase,
CanonicalUrl: seo.CanonicalURL,
RobotsDirective: seo.RobotsDirective,
TwitterTitle: seo.TwitterTitle,
TwitterDescription: seo.TwitterDescription,
TwitterImage: seo.TwitterImage,
}
return s.invoke(ctx, "set_page_seo", req, &abiv1.ContentSetPageSeoResponse{})
}
func (s *authorStub) UpsertPost(ctx context.Context, params content.UpsertPostParams) (content.UpsertPostResult, error) {
docJSON, err := json.Marshal(params.Document)
if err != nil {
return content.UpsertPostResult{}, fmt.Errorf("content.upsert_post: marshal document: %w", err)
}
req := &abiv1.ContentUpsertPostRequest{
Slug: params.Slug,
Title: params.Title,
DocumentJson: docJSON,
Excerpt: params.Excerpt,
Publish: params.Publish,
IsFeatured: params.IsFeatured,
}
if params.AuthorProfileID != nil {
v := params.AuthorProfileID.String()
req.AuthorProfileId = &v
}
if params.FeaturedImageID != nil {
v := params.FeaturedImageID.String()
req.FeaturedImageId = &v
}
resp := &abiv1.ContentUpsertPostResponse{}
if err := s.invoke(ctx, "upsert_post", req, resp); err != nil {
return content.UpsertPostResult{}, err
}
id, err := uuid.Parse(resp.GetPostId())
if err != nil {
return content.UpsertPostResult{}, fmt.Errorf("content.upsert_post: bad post_id %q: %w", resp.GetPostId(), err)
}
return content.UpsertPostResult{PostID: id, Created: resp.GetCreated()}, nil
}
// pageBlockToProto converts one content.PageBlock to its wire form.
func pageBlockToProto(b content.PageBlock) (*abiv1.PageBlock, error) {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return nil, err
}
return &abiv1.PageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
ContentJson: contentJSON,
HtmlContent: b.HTMLContent,
Slot: b.Slot,
SortOrder: b.SortOrder,
}, nil
}

View File

@ -11,25 +11,64 @@ import (
//
// The bridge shares in-process Go values today; across sandboxes only the
// registration/lookup *surface* serializes — the service value itself cannot
// cross. RegisterService therefore forwards only plugin/service names (the
// value is dropped), and GetService returns nil (availability is checked but a
// typed value cannot be reconstructed guest-side). Typed cross-plugin
// invocation is an open ABI item (core/docs/wasm-abi.md).
type bridgeStub struct{ base }
// 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, _ any) {
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. Callers using
// plugin.GetServiceAs correctly observe (zero, false).
// value across the sandbox boundary, so it always returns nil for remote
// services. Callers using plugin.GetServiceAs correctly observe (zero, false);
// cross-plugin calls go through Invoke.
func (s *bridgeStub) GetService(pluginName, serviceName string) any {
req := &abiv1.BridgeGetServiceRequest{PluginName: pluginName, ServiceName: serviceName}
_ = s.invoke(context.Background(), "get_service", req, &abiv1.BridgeGetServiceResponse{})
return nil
}
// Invoke calls a method on another plugin's registered bridge service with an
// opaque payload (bridge.invoke; the provider answers via HOOK_BRIDGE_CALL or
// its in-process plugin.BridgeInvokable).
func (s *bridgeStub) Invoke(ctx context.Context, pluginName, serviceName, method string, payload []byte) ([]byte, error) {
req := &abiv1.BridgeInvokeRequest{
PluginName: pluginName,
ServiceName: serviceName,
Method: method,
Payload: payload,
}
resp := &abiv1.BridgeInvokeResponse{}
if err := s.invoke(ctx, "invoke", req, resp); err != nil {
return nil, err
}
return resp.GetPayload(), nil
}
// Service returns this plugin's own registered service value, for
// HOOK_BRIDGE_CALL dispatch by the wasm shim.
func (s *bridgeStub) Service(serviceName string) (any, bool) {
v, ok := s.services[serviceName]
return v, ok
}

View File

@ -611,6 +611,137 @@ func TestCapabilityRoundTrip(t *testing.T) {
}
},
},
// --- 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)
}
},
},
}
for _, tc := range cases {

View File

@ -22,11 +22,12 @@ import (
// - MediaPath / AppURL → delivered in LoadRequest.host_config at load
// - CoreServiceBindings → static manifest.core_service_bindings; host mounts
func NewCoreServices(call CallFunc) plugin.CoreServices {
settings := &settingsStub{base{family: "settings", call: call}}
ai := &aiStub{base{family: "ai", call: call}}
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}},
@ -39,11 +40,12 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
ToolRegistry: ai,
AITextCall: ai.textCall,
EmailSender: &emailStub{base{family: "email", call: call}},
Bridge: &bridgeStub{base{family: "bridge", call: call}},
Bridge: &bridgeStub{base: base{family: "bridge", call: call}},
ReviewSubmitter: &reviewsStub{base{family: "reviews", call: call}},
BadgeRefresher: &badgesStub{base{family: "badges", call: call}},
JobRunner: &jobsStub{base{family: "jobs", call: call}},
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
RAGService: NewRAGStub(call),
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
}
}

View File

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

View File

@ -42,3 +42,12 @@ func (s *settingsStub) UpdateSiteSetting(ctx context.Context, key string, value
req := &abiv1.SettingsUpdateSiteSettingRequest{Key: key, ValueJson: valueJSON}
return s.invoke(ctx, "update_site_setting", req, &abiv1.SettingsUpdateSiteSettingResponse{})
}
func (s *settingsStub) UpdatePluginSettings(ctx context.Context, pluginName string, settingsMap map[string]any) error {
settingsJSON, err := json.Marshal(settingsMap)
if err != nil {
return err
}
req := &abiv1.SettingsUpdatePluginSettingsRequest{PluginName: pluginName, SettingsJson: settingsJSON}
return s.invoke(ctx, "update_plugin_settings", req, &abiv1.SettingsUpdatePluginSettingsResponse{})
}

View File

@ -0,0 +1,3 @@
symposiumseeder
seed_forum"{"n":1}

View File

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

View File

@ -0,0 +1,2 @@
/pricingPricing"landing

View File

@ -0,0 +1,2 @@
$44444444-4444-4444-4444-444444444444

View File

@ -0,0 +1,2 @@
$44444444-4444-4444-4444-444444444444

View File

@ -0,0 +1,4 @@
$44444444-4444-4444-4444-4444444444445
htmlHero{"align":"center"}" <h1>Hi</h1>*main0
ctaCTA{"label":"Go"}0

View File

@ -0,0 +1,2 @@
$44444444-4444-4444-4444-444444444444Pricing — AcmePlans and pricing.

View File

@ -0,0 +1,2 @@
helloHello{"type":"doc"}"hello*$22222222-2222-2222-2222-2222222222222$99999999-9999-9999-9999-9999999999998

View File

@ -0,0 +1,2 @@
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb

View File

@ -0,0 +1,2 @@
$99999999-9999-9999-9999-999999999999hero.jpg"hero*seed2myplugin

View File

@ -0,0 +1,2 @@
mainDocs"/docs(

View File

@ -0,0 +1,4 @@
4
/Home"homepage*
htmlHero{"x":1}*main0H

View File

@ -0,0 +1,2 @@
{"site_name":"Acme"}

View File

@ -0,0 +1,2 @@
myplugin{"enabled":true}

View File

@ -12,6 +12,7 @@ import (
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/ai"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/bnwasm"
@ -102,8 +103,11 @@ func newGuest(reg plugin.PluginRegistration) *guest {
}
// runRegister invokes Register (or RegisterWithProvisioner with a no-op
// provisioner — provisioning itself runs host-side at load) against capture
// registries, converting panics to errors.
// provisioner) against capture registries, converting panics to errors.
// Register-time provisioning cannot cross the ABI (DESCRIBE stubs every host
// function to fail), so RegisterWithProvisioner's provisioner is inert here;
// wasm plugins provision from Load via deps.Provisioner (the provisioner.*
// capability family, WO-WZ-019) instead.
func runRegister(reg plugin.PluginRegistration, tr *captureTemplateRegistry, br *captureBlockRegistry) (err error) {
defer func() {
if r := recover(); r != nil {
@ -186,6 +190,14 @@ func (g *guest) invoke(hookID uint32, req []byte) (out []byte) {
payload, abiErr = g.ragFetch(ctx, env.GetPayload())
case abiv1.Hook_HOOK_MEDIA_HOOK:
payload, abiErr = g.mediaHook(ctx, env.GetPayload())
case abiv1.Hook_HOOK_AI_TOOL_CALL:
payload, abiErr = g.aiToolCall(ctx, env.GetPayload())
case abiv1.Hook_HOOK_BRIDGE_CALL:
payload, abiErr = g.bridgeCall(ctx, env.GetPayload())
case abiv1.Hook_HOOK_DIRECTORY_PANEL_SECTION:
payload, abiErr = g.directoryPanelSection(env.GetPayload())
case abiv1.Hook_HOOK_DIRECTORY_PIN_DECORATOR:
payload, abiErr = g.directoryPinDecorator(env.GetPayload())
default:
abiErr = &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
@ -390,9 +402,16 @@ func (g *guest) runJob(ctx context.Context, payload []byte) (proto.Message, *abi
Message: "unknown job type " + req.GetJobType(),
}
}
// Job progress reporting needs a jobs.progress host function — an ABI
// open item (core/docs/wasm-abi.md); v1 discards progress updates.
progress := func(current, total int, message string) {}
// Progress crosses as best-effort jobs.progress host calls; the host
// correlates them to the running job via the HOOK_JOB call's context. A
// nil transport (native builds) discards updates.
progress := func(current, total int, message string) {
if capTransport == nil {
return
}
preq := &abiv1.JobsProgressRequest{Current: int32(current), Total: int32(total), Message: message}
_ = capTransport("jobs.progress", preq, &abiv1.JobsProgressResponse{})
}
result, err := handler(ctx, req.GetConfigJson(), progress)
if err != nil {
return nil, internalError(err.Error())
@ -533,6 +552,163 @@ func (g *guest) ragFetcher(contentType string) (plugin.ContentFetcher, bool) {
return g.rag.Fetcher(contentType)
}
// toolLookup / serviceLookup are the recording-stub accessors dispatch needs
// for HOOK_AI_TOOL_CALL / HOOK_BRIDGE_CALL. The caps stubs implement them;
// asserting interfaces keeps the stub types unexported.
type toolLookup interface {
Tool(slug string) (*ai.ToolDefinition, bool)
}
type serviceLookup interface {
Service(serviceName string) (any, bool)
}
// aiToolCall executes a guest-registered AI tool handler (HOOK_AI_TOOL_CALL).
// Tools must be registered in Register (every pooled instance runs it) for the
// lookup to succeed on any instance — see wasm-abi.md §"Per-instance state".
func (g *guest) aiToolCall(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.AiToolCallRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("AiToolCallRequest", err)
}
reg, ok := g.services.ToolRegistry.(toolLookup)
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "tool registry does not record handlers",
}
}
tool, ok := reg.Tool(req.GetSlug())
if !ok || tool.Handler == nil {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "no handler for AI tool " + req.GetSlug() + " on this instance (register tools in Register, not Load)",
}
}
var params map[string]any
if raw := req.GetParamsJson(); len(raw) > 0 {
if err := json.Unmarshal(raw, &params); err != nil {
return nil, decodeError("AiToolCallRequest.params_json", err)
}
}
result, err := tool.Handler(ctx, params)
if err != nil {
return nil, internalError(err.Error())
}
resp := &abiv1.AiToolCallResponse{}
if result != nil {
resp.Content = result.Content
resp.ErrorMessage = result.Error
}
return resp, nil
}
// bridgeCall invokes a method on one of THIS plugin's registered bridge
// services (HOOK_BRIDGE_CALL; the consumer side is the bridge.invoke
// capability). The service value must implement plugin.BridgeInvokable and be
// registered in Register so every pooled instance has it.
func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.BridgeCallRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("BridgeCallRequest", err)
}
lookup, ok := g.services.Bridge.(serviceLookup)
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "bridge does not record services",
}
}
svc, ok := lookup.Service(req.GetServiceName())
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "no bridge service " + req.GetServiceName() + " on this instance (register services in Register, not Load)",
}
}
invokable, ok := svc.(plugin.BridgeInvokable)
if !ok {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable",
}
}
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
if err != nil {
return nil, internalError(err.Error())
}
return &abiv1.BridgeCallResponse{Payload: out}, nil
}
// directoryExtensions resolves the registration's DirectoryExtensions value,
// nil when the plugin declares none.
func (g *guest) directoryExtensions() *plugin.DirectoryExtensions {
if g.reg.DirectoryExtensions == nil {
return nil
}
return g.reg.DirectoryExtensions()
}
// directoryPanelSection renders the index-th registered panel section
// (HOOK_DIRECTORY_PANEL_SECTION).
func (g *guest) directoryPanelSection(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.DirectoryPanelSectionRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("DirectoryPanelSectionRequest", err)
}
ext := g.directoryExtensions()
idx := int(req.GetIndex())
if ext == nil || idx >= len(ext.PanelSections) || ext.PanelSections[idx] == nil {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: fmt.Sprintf("no directory panel section at index %d", idx),
}
}
var data map[string]any
if raw := req.GetDataJson(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return nil, decodeError("DirectoryPanelSectionRequest.data_json", err)
}
}
return &abiv1.DirectoryPanelSectionResponse{Html: ext.PanelSections[idx](data)}, nil
}
// directoryPinDecorator runs the index-th registered pin decorator, which
// mutates the pin map in place; the mutated pin is returned
// (HOOK_DIRECTORY_PIN_DECORATOR).
func (g *guest) directoryPinDecorator(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.DirectoryPinDecoratorRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("DirectoryPinDecoratorRequest", err)
}
ext := g.directoryExtensions()
idx := int(req.GetIndex())
if ext == nil || idx >= len(ext.PinDecorators) || ext.PinDecorators[idx] == nil {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: fmt.Sprintf("no directory pin decorator at index %d", idx),
}
}
pin := map[string]any{}
if raw := req.GetPinJson(); len(raw) > 0 {
if err := json.Unmarshal(raw, &pin); err != nil {
return nil, decodeError("DirectoryPinDecoratorRequest.pin_json", err)
}
}
var data map[string]any
if raw := req.GetDataJson(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return nil, decodeError("DirectoryPinDecoratorRequest.data_json", err)
}
}
ext.PinDecorators[idx](pin, data)
pinJSON, err := json.Marshal(pin)
if err != nil {
return nil, internalError("marshal mutated pin: " + err.Error())
}
return &abiv1.DirectoryPinDecoratorResponse{PinJson: pinJSON}, nil
}
// sortedKeys returns the map's keys in sorted order (deterministic manifests).
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))

View File

@ -8,9 +8,13 @@ type Settings interface {
GetPluginSettings(ctx context.Context, pluginName string) (map[string]any, error)
}
// Updater allows plugins to modify site settings.
// Updater allows plugins to modify site settings and their own plugin
// settings.
type Updater interface {
UpdateSiteSetting(ctx context.Context, key string, value any) error
// UpdatePluginSettings replaces the plugin's own settings map. Across the
// wasm ABI the host enforces that pluginName matches the calling plugin.
UpdatePluginSettings(ctx context.Context, pluginName string, settings map[string]any) error
}
// GetStringOr returns a string value from a map, or defaultVal if not found/wrong type.