Compare commits

...

7 Commits
v0.3.2 ... main

Author SHA1 Message Date
Alex Dunmow
5f0cc20bbf chore: checkpoint all working changes 2026-09-05 21:14:15 +08:00
Alex Dunmow
f14846d758 fix(wasmguest): expose host services during register 2026-08-25 12:42:42 +08:00
Alex Dunmow
e2dcbc1fc3 feat: authenticate bridge callers and declare SDK compatibility 2026-08-20 00:24:49 +08:00
Alex Dunmow
731d6b51d1 feat(plugin): declare admin API compatibility 2026-08-19 23:21:47 +08:00
Alex Dunmow
abef5b70da fix(render): reject active-content link schemes 2026-08-19 22:44:40 +08:00
Alex Dunmow
fb58679b6f feat(abi): add keyed page and render response contracts 2026-08-19 21:50:28 +08:00
Alex Dunmow
301f286c2f feat(plugin): add durable keyed load-once hooks 2026-08-10 23:04:47 +08:00
47 changed files with 1701 additions and 355 deletions

View File

@ -770,6 +770,7 @@ message ProvisionerEnsureSettingResponse {}
// PageSeed mirrors plugin.PageConfig.
message PageSeed {
string slug = 1;
// Legacy slug-based parent reference. Prefer parent_key for new page seeds.
string parent_slug = 2;
string title = 3;
string template_key = 4;
@ -779,6 +780,16 @@ message PageSeed {
string detail_slug_field = 8;
bool reconcile_blocks = 9;
bool reconcile_template = 10;
// Stable identity scoped to the declaring plugin.
string key = 11;
// Stable identity of a parent page declared by the same plugin.
string parent_key = 12;
// Applied only when the page is created; later reconciliation does not move
// an existing page when this value changes.
string mount_path = 13;
// Shallowly add absent top-level defaults to matching plugin-owned block
// assignments while preserving all present values, including empty values.
bool merge_missing_block_content = 14;
}
message ProvisionerEnsurePageRequest {
@ -802,6 +813,10 @@ message ProvisionerEnsureMenuItemRequest {
string url = 3;
string page_slug = 4;
int32 sort_order = 5;
// Stable plugin/page-key target. When set, these identify the provisioned
// page independently of its current slug.
string page_plugin = 6;
string page_key = 7;
}
message ProvisionerEnsureMenuItemResponse {}

View File

@ -61,6 +61,9 @@ enum Hook {
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
HOOK_DIRECTORY_PIN_DECORATOR = 15;
// Run one keyed PluginRegistration.LoadOnce callback:
// payload = LoadOnceRequest / LoadOnceResponse.
HOOK_LOAD_ONCE = 16;
}
// InvokeRequest is the hostguest call envelope.
@ -145,6 +148,15 @@ message HostConfig {
message LoadResponse {}
// LoadOnceRequest identifies the callback selected by the host from the
// manifest's load_once_keys. The host records the key only after a successful
// response; failed callbacks may be retried on a later load.
message LoadOnceRequest {
string key = 1;
}
message LoadOnceResponse {}
message UnloadRequest {}
message UnloadResponse {}
@ -234,6 +246,9 @@ message BridgeCallRequest {
string service_name = 1;
string method = 2;
bytes payload = 3;
// Host-authenticated identity of the consumer plugin. The host derives this
// from the loaded caller; providers must not trust identity in payload.
string caller_plugin = 4;
}
message BridgeCallResponse {

View File

@ -164,6 +164,12 @@ message PluginManifest {
// every procedure against rbac_method_roles before advertising or invoking
// it, so descriptor metadata cannot widen the plugin's authorization map.
repeated MCPToolDescriptor mcp_tools = 38;
// Stable keys for PluginRegistration.LoadOnce callbacks. The host persists
// successful (plugin,key) executions and invokes HOOK_LOAD_ONCE only for
// keys it has never completed. Changing a key is the explicit migration
// mechanism for running a replacement callback once.
repeated string load_once_keys = 39;
}
// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP

View File

@ -44,6 +44,23 @@ message PoweredBlock {
string template = 1;
// JSON encoding of the template data map (map[string]any).
bytes data_json = 2;
// Requested HTTP response status. Before writing headers, the host accepts
// only 200599, uses the highest status class, and keeps the first result
// encountered within that class.
int32 status_code = 3;
// Structured document metadata merged by the host into the page head. The
// first non-empty value for each field wins in deterministic render order.
DocumentMetadata metadata = 4;
}
// DocumentMetadata is the safe, shared subset of document head data a plugin
// may provide. The host validates and escapes every value before rendering.
message DocumentMetadata {
string title = 1;
string description = 2;
string canonical_url = 3;
string image_url = 4;
string robots = 5;
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
@ -76,6 +93,11 @@ message TemplateDocument {
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
// ignored the lang field wins.
map<string, string> html_attrs = 6;
// Requested HTTP response status. The host applies the same validation and
// precedence rules as PoweredBlock.status_code.
int32 status_code = 7;
// Structured document metadata merged by the host into the page head.
DocumentMetadata metadata = 8;
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
@ -181,6 +203,10 @@ message PageContext {
string title = 3;
string post_type = 4; // "page", "post", "master", "system"
string status = 5; // "published", "draft", "scheduled"
// Slug derived from the resolved page hierarchy.
string derived_slug = 6;
// Effective mount path after host inheritance and normalization.
string mount_path = 7;
}
// PostContext mirrors blocks.PostContext.
@ -276,4 +302,8 @@ message BlockContext {
bytes detail_row_data_json = 36;
string blog_index_url = 37;
string category_page_url = 38;
// Slug derived from the resolved page hierarchy.
string derived_slug = 39;
// Effective mount path after host inheritance and normalization.
string mount_path = 40;
}

View File

@ -6668,19 +6668,30 @@ func (*ProvisionerEnsureSettingResponse) Descriptor() ([]byte, []int) {
// PageSeed mirrors plugin.PageConfig.
type PageSeed struct {
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
ParentSlug string `protobuf:"bytes,2,opt,name=parent_slug,json=parentSlug,proto3" json:"parent_slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
TemplateKey string `protobuf:"bytes,4,opt,name=template_key,json=templateKey,proto3" json:"template_key,omitempty"`
Blocks []*PageBlock `protobuf:"bytes,5,rep,name=blocks,proto3" json:"blocks,omitempty"`
DetailSourceType string `protobuf:"bytes,6,opt,name=detail_source_type,json=detailSourceType,proto3" json:"detail_source_type,omitempty"`
DetailSourceKey string `protobuf:"bytes,7,opt,name=detail_source_key,json=detailSourceKey,proto3" json:"detail_source_key,omitempty"`
DetailSlugField string `protobuf:"bytes,8,opt,name=detail_slug_field,json=detailSlugField,proto3" json:"detail_slug_field,omitempty"`
ReconcileBlocks bool `protobuf:"varint,9,opt,name=reconcile_blocks,json=reconcileBlocks,proto3" json:"reconcile_blocks,omitempty"`
ReconcileTemplate bool `protobuf:"varint,10,opt,name=reconcile_template,json=reconcileTemplate,proto3" json:"reconcile_template,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Slug string `protobuf:"bytes,1,opt,name=slug,proto3" json:"slug,omitempty"`
// Legacy slug-based parent reference. Prefer parent_key for new page seeds.
ParentSlug string `protobuf:"bytes,2,opt,name=parent_slug,json=parentSlug,proto3" json:"parent_slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
TemplateKey string `protobuf:"bytes,4,opt,name=template_key,json=templateKey,proto3" json:"template_key,omitempty"`
Blocks []*PageBlock `protobuf:"bytes,5,rep,name=blocks,proto3" json:"blocks,omitempty"`
DetailSourceType string `protobuf:"bytes,6,opt,name=detail_source_type,json=detailSourceType,proto3" json:"detail_source_type,omitempty"`
DetailSourceKey string `protobuf:"bytes,7,opt,name=detail_source_key,json=detailSourceKey,proto3" json:"detail_source_key,omitempty"`
DetailSlugField string `protobuf:"bytes,8,opt,name=detail_slug_field,json=detailSlugField,proto3" json:"detail_slug_field,omitempty"`
ReconcileBlocks bool `protobuf:"varint,9,opt,name=reconcile_blocks,json=reconcileBlocks,proto3" json:"reconcile_blocks,omitempty"`
ReconcileTemplate bool `protobuf:"varint,10,opt,name=reconcile_template,json=reconcileTemplate,proto3" json:"reconcile_template,omitempty"`
// Stable identity scoped to the declaring plugin.
Key string `protobuf:"bytes,11,opt,name=key,proto3" json:"key,omitempty"`
// Stable identity of a parent page declared by the same plugin.
ParentKey string `protobuf:"bytes,12,opt,name=parent_key,json=parentKey,proto3" json:"parent_key,omitempty"`
// Applied only when the page is created; later reconciliation does not move
// an existing page when this value changes.
MountPath string `protobuf:"bytes,13,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
// Shallowly add absent top-level defaults to matching plugin-owned block
// assignments while preserving all present values, including empty values.
MergeMissingBlockContent bool `protobuf:"varint,14,opt,name=merge_missing_block_content,json=mergeMissingBlockContent,proto3" json:"merge_missing_block_content,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PageSeed) Reset() {
@ -6783,6 +6794,34 @@ func (x *PageSeed) GetReconcileTemplate() bool {
return false
}
func (x *PageSeed) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
func (x *PageSeed) GetParentKey() string {
if x != nil {
return x.ParentKey
}
return ""
}
func (x *PageSeed) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
func (x *PageSeed) GetMergeMissingBlockContent() bool {
if x != nil {
return x.MergeMissingBlockContent
}
return false
}
type ProvisionerEnsurePageRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Page *PageSeed `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"`
@ -6947,12 +6986,16 @@ func (*ProvisionerOverrideSiteSettingsResponse) Descriptor() ([]byte, []int) {
// ProvisionerEnsureMenuItemRequest mirrors plugin.MenuItemConfig under a
// named menu.
type ProvisionerEnsureMenuItemRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
MenuName string `protobuf:"bytes,1,opt,name=menu_name,json=menuName,proto3" json:"menu_name,omitempty"`
Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
PageSlug string `protobuf:"bytes,4,opt,name=page_slug,json=pageSlug,proto3" json:"page_slug,omitempty"`
SortOrder int32 `protobuf:"varint,5,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
MenuName string `protobuf:"bytes,1,opt,name=menu_name,json=menuName,proto3" json:"menu_name,omitempty"`
Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
PageSlug string `protobuf:"bytes,4,opt,name=page_slug,json=pageSlug,proto3" json:"page_slug,omitempty"`
SortOrder int32 `protobuf:"varint,5,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
// Stable plugin/page-key target. When set, these identify the provisioned
// page independently of its current slug.
PagePlugin string `protobuf:"bytes,6,opt,name=page_plugin,json=pagePlugin,proto3" json:"page_plugin,omitempty"`
PageKey string `protobuf:"bytes,7,opt,name=page_key,json=pageKey,proto3" json:"page_key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -7022,6 +7065,20 @@ func (x *ProvisionerEnsureMenuItemRequest) GetSortOrder() int32 {
return 0
}
func (x *ProvisionerEnsureMenuItemRequest) GetPagePlugin() string {
if x != nil {
return x.PagePlugin
}
return ""
}
func (x *ProvisionerEnsureMenuItemRequest) GetPageKey() string {
if x != nil {
return x.PageKey
}
return ""
}
type ProvisionerEnsureMenuItemResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@ -8786,7 +8843,7 @@ const file_v1_capability_proto_rawDesc = "" +
"\x1fProvisionerEnsureSettingRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12,\n" +
"\x12default_value_json\x18\x02 \x01(\fR\x10defaultValueJson\"\"\n" +
" ProvisionerEnsureSettingResponse\"\x83\x03\n" +
" ProvisionerEnsureSettingResponse\"\x92\x04\n" +
"\bPageSeed\x12\x12\n" +
"\x04slug\x18\x01 \x01(\tR\x04slug\x12\x1f\n" +
"\vparent_slug\x18\x02 \x01(\tR\n" +
@ -8799,20 +8856,29 @@ const file_v1_capability_proto_rawDesc = "" +
"\x11detail_slug_field\x18\b \x01(\tR\x0fdetailSlugField\x12)\n" +
"\x10reconcile_blocks\x18\t \x01(\bR\x0freconcileBlocks\x12-\n" +
"\x12reconcile_template\x18\n" +
" \x01(\bR\x11reconcileTemplate\"D\n" +
" \x01(\bR\x11reconcileTemplate\x12\x10\n" +
"\x03key\x18\v \x01(\tR\x03key\x12\x1d\n" +
"\n" +
"parent_key\x18\f \x01(\tR\tparentKey\x12\x1d\n" +
"\n" +
"mount_path\x18\r \x01(\tR\tmountPath\x12=\n" +
"\x1bmerge_missing_block_content\x18\x0e \x01(\bR\x18mergeMissingBlockContent\"D\n" +
"\x1cProvisionerEnsurePageRequest\x12$\n" +
"\x04page\x18\x01 \x01(\v2\x10.abi.v1.PageSeedR\x04page\"\x1f\n" +
"\x1dProvisionerEnsurePageResponse\"O\n" +
"&ProvisionerOverrideSiteSettingsRequest\x12%\n" +
"\x0eoverrides_json\x18\x01 \x01(\fR\roverridesJson\")\n" +
"'ProvisionerOverrideSiteSettingsResponse\"\xa3\x01\n" +
"'ProvisionerOverrideSiteSettingsResponse\"\xdf\x01\n" +
" ProvisionerEnsureMenuItemRequest\x12\x1b\n" +
"\tmenu_name\x18\x01 \x01(\tR\bmenuName\x12\x14\n" +
"\x05label\x18\x02 \x01(\tR\x05label\x12\x10\n" +
"\x03url\x18\x03 \x01(\tR\x03url\x12\x1b\n" +
"\tpage_slug\x18\x04 \x01(\tR\bpageSlug\x12\x1d\n" +
"\n" +
"sort_order\x18\x05 \x01(\x05R\tsortOrder\"#\n" +
"sort_order\x18\x05 \x01(\x05R\tsortOrder\x12\x1f\n" +
"\vpage_plugin\x18\x06 \x01(\tR\n" +
"pagePlugin\x12\x19\n" +
"\bpage_key\x18\a \x01(\tR\apageKey\"#\n" +
"!ProvisionerEnsureMenuItemResponse\"\x87\x01\n" +
")ProvisionerRegisterEmbeddingConfigRequest\x12\x1b\n" +
"\ttable_key\x18\x01 \x01(\tR\btableKey\x12#\n" +

View File

@ -78,6 +78,9 @@ const (
// Run one directory pin decorator (DirectoryExtensions.PinDecorators):
// payload = DirectoryPinDecoratorRequest / DirectoryPinDecoratorResponse.
Hook_HOOK_DIRECTORY_PIN_DECORATOR Hook = 15
// Run one keyed PluginRegistration.LoadOnce callback:
// payload = LoadOnceRequest / LoadOnceResponse.
Hook_HOOK_LOAD_ONCE Hook = 16
)
// Enum value maps for Hook.
@ -99,6 +102,7 @@ var (
13: "HOOK_BRIDGE_CALL",
14: "HOOK_DIRECTORY_PANEL_SECTION",
15: "HOOK_DIRECTORY_PIN_DECORATOR",
16: "HOOK_LOAD_ONCE",
}
Hook_value = map[string]int32{
"HOOK_UNSPECIFIED": 0,
@ -117,6 +121,7 @@ var (
"HOOK_BRIDGE_CALL": 13,
"HOOK_DIRECTORY_PANEL_SECTION": 14,
"HOOK_DIRECTORY_PIN_DECORATOR": 15,
"HOOK_LOAD_ONCE": 16,
}
)
@ -627,6 +632,89 @@ func (*LoadResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{7}
}
// LoadOnceRequest identifies the callback selected by the host from the
// manifest's load_once_keys. The host records the key only after a successful
// response; failed callbacks may be retried on a later load.
type LoadOnceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoadOnceRequest) Reset() {
*x = LoadOnceRequest{}
mi := &file_v1_invoke_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoadOnceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadOnceRequest) ProtoMessage() {}
func (x *LoadOnceRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[8]
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 LoadOnceRequest.ProtoReflect.Descriptor instead.
func (*LoadOnceRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{8}
}
func (x *LoadOnceRequest) GetKey() string {
if x != nil {
return x.Key
}
return ""
}
type LoadOnceResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoadOnceResponse) Reset() {
*x = LoadOnceResponse{}
mi := &file_v1_invoke_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoadOnceResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LoadOnceResponse) ProtoMessage() {}
func (x *LoadOnceResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[9]
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 LoadOnceResponse.ProtoReflect.Descriptor instead.
func (*LoadOnceResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{9}
}
type UnloadRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@ -635,7 +723,7 @@ type UnloadRequest struct {
func (x *UnloadRequest) Reset() {
*x = UnloadRequest{}
mi := &file_v1_invoke_proto_msgTypes[8]
mi := &file_v1_invoke_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -647,7 +735,7 @@ func (x *UnloadRequest) String() string {
func (*UnloadRequest) ProtoMessage() {}
func (x *UnloadRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[8]
mi := &file_v1_invoke_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -660,7 +748,7 @@ func (x *UnloadRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UnloadRequest.ProtoReflect.Descriptor instead.
func (*UnloadRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{8}
return file_v1_invoke_proto_rawDescGZIP(), []int{10}
}
type UnloadResponse struct {
@ -671,7 +759,7 @@ type UnloadResponse struct {
func (x *UnloadResponse) Reset() {
*x = UnloadResponse{}
mi := &file_v1_invoke_proto_msgTypes[9]
mi := &file_v1_invoke_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -683,7 +771,7 @@ func (x *UnloadResponse) String() string {
func (*UnloadResponse) ProtoMessage() {}
func (x *UnloadResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[9]
mi := &file_v1_invoke_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -696,7 +784,7 @@ func (x *UnloadResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use UnloadResponse.ProtoReflect.Descriptor instead.
func (*UnloadResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{9}
return file_v1_invoke_proto_rawDescGZIP(), []int{11}
}
// JobRequest dispatches one background job to the guest handler registered
@ -712,7 +800,7 @@ type JobRequest struct {
func (x *JobRequest) Reset() {
*x = JobRequest{}
mi := &file_v1_invoke_proto_msgTypes[10]
mi := &file_v1_invoke_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -724,7 +812,7 @@ func (x *JobRequest) String() string {
func (*JobRequest) ProtoMessage() {}
func (x *JobRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[10]
mi := &file_v1_invoke_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -737,7 +825,7 @@ func (x *JobRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use JobRequest.ProtoReflect.Descriptor instead.
func (*JobRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{10}
return file_v1_invoke_proto_rawDescGZIP(), []int{12}
}
func (x *JobRequest) GetJobType() string {
@ -764,7 +852,7 @@ type JobResponse struct {
func (x *JobResponse) Reset() {
*x = JobResponse{}
mi := &file_v1_invoke_proto_msgTypes[11]
mi := &file_v1_invoke_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -776,7 +864,7 @@ func (x *JobResponse) String() string {
func (*JobResponse) ProtoMessage() {}
func (x *JobResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[11]
mi := &file_v1_invoke_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -789,7 +877,7 @@ func (x *JobResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use JobResponse.ProtoReflect.Descriptor instead.
func (*JobResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{11}
return file_v1_invoke_proto_rawDescGZIP(), []int{13}
}
func (x *JobResponse) GetResultJson() []byte {
@ -811,7 +899,7 @@ type RagFetchRequest struct {
func (x *RagFetchRequest) Reset() {
*x = RagFetchRequest{}
mi := &file_v1_invoke_proto_msgTypes[12]
mi := &file_v1_invoke_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -823,7 +911,7 @@ func (x *RagFetchRequest) String() string {
func (*RagFetchRequest) ProtoMessage() {}
func (x *RagFetchRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[12]
mi := &file_v1_invoke_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -836,7 +924,7 @@ func (x *RagFetchRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RagFetchRequest.ProtoReflect.Descriptor instead.
func (*RagFetchRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{12}
return file_v1_invoke_proto_rawDescGZIP(), []int{14}
}
func (x *RagFetchRequest) GetContentType() string {
@ -864,7 +952,7 @@ type RagFetchResponse struct {
func (x *RagFetchResponse) Reset() {
*x = RagFetchResponse{}
mi := &file_v1_invoke_proto_msgTypes[13]
mi := &file_v1_invoke_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -876,7 +964,7 @@ func (x *RagFetchResponse) String() string {
func (*RagFetchResponse) ProtoMessage() {}
func (x *RagFetchResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[13]
mi := &file_v1_invoke_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -889,7 +977,7 @@ func (x *RagFetchResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RagFetchResponse.ProtoReflect.Descriptor instead.
func (*RagFetchResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{13}
return file_v1_invoke_proto_rawDescGZIP(), []int{15}
}
func (x *RagFetchResponse) GetTitle() string {
@ -921,7 +1009,7 @@ type MediaHookRequest struct {
func (x *MediaHookRequest) Reset() {
*x = MediaHookRequest{}
mi := &file_v1_invoke_proto_msgTypes[14]
mi := &file_v1_invoke_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -933,7 +1021,7 @@ func (x *MediaHookRequest) String() string {
func (*MediaHookRequest) ProtoMessage() {}
func (x *MediaHookRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[14]
mi := &file_v1_invoke_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -946,7 +1034,7 @@ func (x *MediaHookRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaHookRequest.ProtoReflect.Descriptor instead.
func (*MediaHookRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{14}
return file_v1_invoke_proto_rawDescGZIP(), []int{16}
}
func (x *MediaHookRequest) GetEvent() isMediaHookRequest_Event {
@ -998,7 +1086,7 @@ type MediaHookResponse struct {
func (x *MediaHookResponse) Reset() {
*x = MediaHookResponse{}
mi := &file_v1_invoke_proto_msgTypes[15]
mi := &file_v1_invoke_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1010,7 +1098,7 @@ func (x *MediaHookResponse) String() string {
func (*MediaHookResponse) ProtoMessage() {}
func (x *MediaHookResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[15]
mi := &file_v1_invoke_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1023,7 +1111,7 @@ func (x *MediaHookResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaHookResponse.ProtoReflect.Descriptor instead.
func (*MediaHookResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{15}
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
}
// MediaAnalyzedEvent mirrors plugin.MediaAnalyzedEvent (UUIDs as strings).
@ -1045,7 +1133,7 @@ type MediaAnalyzedEvent struct {
func (x *MediaAnalyzedEvent) Reset() {
*x = MediaAnalyzedEvent{}
mi := &file_v1_invoke_proto_msgTypes[16]
mi := &file_v1_invoke_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1057,7 +1145,7 @@ func (x *MediaAnalyzedEvent) String() string {
func (*MediaAnalyzedEvent) ProtoMessage() {}
func (x *MediaAnalyzedEvent) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[16]
mi := &file_v1_invoke_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1070,7 +1158,7 @@ func (x *MediaAnalyzedEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use MediaAnalyzedEvent.ProtoReflect.Descriptor instead.
func (*MediaAnalyzedEvent) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{16}
return file_v1_invoke_proto_rawDescGZIP(), []int{18}
}
func (x *MediaAnalyzedEvent) GetMediaId() string {
@ -1156,7 +1244,7 @@ type AiToolCallRequest struct {
func (x *AiToolCallRequest) Reset() {
*x = AiToolCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1168,7 +1256,7 @@ func (x *AiToolCallRequest) String() string {
func (*AiToolCallRequest) ProtoMessage() {}
func (x *AiToolCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[17]
mi := &file_v1_invoke_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1181,7 +1269,7 @@ func (x *AiToolCallRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AiToolCallRequest.ProtoReflect.Descriptor instead.
func (*AiToolCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{17}
return file_v1_invoke_proto_rawDescGZIP(), []int{19}
}
func (x *AiToolCallRequest) GetSlug() string {
@ -1211,7 +1299,7 @@ type AiToolCallResponse struct {
func (x *AiToolCallResponse) Reset() {
*x = AiToolCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[18]
mi := &file_v1_invoke_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1223,7 +1311,7 @@ func (x *AiToolCallResponse) String() string {
func (*AiToolCallResponse) ProtoMessage() {}
func (x *AiToolCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[18]
mi := &file_v1_invoke_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1236,7 +1324,7 @@ func (x *AiToolCallResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use AiToolCallResponse.ProtoReflect.Descriptor instead.
func (*AiToolCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{18}
return file_v1_invoke_proto_rawDescGZIP(), []int{20}
}
func (x *AiToolCallResponse) GetContent() string {
@ -1258,17 +1346,20 @@ func (x *AiToolCallResponse) GetErrorMessage() string {
// 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"`
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"`
// Host-authenticated identity of the consumer plugin. The host derives this
// from the loaded caller; providers must not trust identity in payload.
CallerPlugin string `protobuf:"bytes,4,opt,name=caller_plugin,json=callerPlugin,proto3" json:"caller_plugin,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BridgeCallRequest) Reset() {
*x = BridgeCallRequest{}
mi := &file_v1_invoke_proto_msgTypes[19]
mi := &file_v1_invoke_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1280,7 +1371,7 @@ func (x *BridgeCallRequest) String() string {
func (*BridgeCallRequest) ProtoMessage() {}
func (x *BridgeCallRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[19]
mi := &file_v1_invoke_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1293,7 +1384,7 @@ func (x *BridgeCallRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use BridgeCallRequest.ProtoReflect.Descriptor instead.
func (*BridgeCallRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{19}
return file_v1_invoke_proto_rawDescGZIP(), []int{21}
}
func (x *BridgeCallRequest) GetServiceName() string {
@ -1317,6 +1408,13 @@ func (x *BridgeCallRequest) GetPayload() []byte {
return nil
}
func (x *BridgeCallRequest) GetCallerPlugin() string {
if x != nil {
return x.CallerPlugin
}
return ""
}
type BridgeCallResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
@ -1326,7 +1424,7 @@ type BridgeCallResponse struct {
func (x *BridgeCallResponse) Reset() {
*x = BridgeCallResponse{}
mi := &file_v1_invoke_proto_msgTypes[20]
mi := &file_v1_invoke_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1338,7 +1436,7 @@ func (x *BridgeCallResponse) String() string {
func (*BridgeCallResponse) ProtoMessage() {}
func (x *BridgeCallResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[20]
mi := &file_v1_invoke_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1351,7 +1449,7 @@ func (x *BridgeCallResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use BridgeCallResponse.ProtoReflect.Descriptor instead.
func (*BridgeCallResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{20}
return file_v1_invoke_proto_rawDescGZIP(), []int{22}
}
func (x *BridgeCallResponse) GetPayload() []byte {
@ -1375,7 +1473,7 @@ type DirectoryPanelSectionRequest struct {
func (x *DirectoryPanelSectionRequest) Reset() {
*x = DirectoryPanelSectionRequest{}
mi := &file_v1_invoke_proto_msgTypes[21]
mi := &file_v1_invoke_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1387,7 +1485,7 @@ func (x *DirectoryPanelSectionRequest) String() string {
func (*DirectoryPanelSectionRequest) ProtoMessage() {}
func (x *DirectoryPanelSectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[21]
mi := &file_v1_invoke_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1400,7 +1498,7 @@ func (x *DirectoryPanelSectionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPanelSectionRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{21}
return file_v1_invoke_proto_rawDescGZIP(), []int{23}
}
func (x *DirectoryPanelSectionRequest) GetIndex() uint32 {
@ -1426,7 +1524,7 @@ type DirectoryPanelSectionResponse struct {
func (x *DirectoryPanelSectionResponse) Reset() {
*x = DirectoryPanelSectionResponse{}
mi := &file_v1_invoke_proto_msgTypes[22]
mi := &file_v1_invoke_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1438,7 +1536,7 @@ func (x *DirectoryPanelSectionResponse) String() string {
func (*DirectoryPanelSectionResponse) ProtoMessage() {}
func (x *DirectoryPanelSectionResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[22]
mi := &file_v1_invoke_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1451,7 +1549,7 @@ func (x *DirectoryPanelSectionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPanelSectionResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPanelSectionResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{22}
return file_v1_invoke_proto_rawDescGZIP(), []int{24}
}
func (x *DirectoryPanelSectionResponse) GetHtml() string {
@ -1476,7 +1574,7 @@ type DirectoryPinDecoratorRequest struct {
func (x *DirectoryPinDecoratorRequest) Reset() {
*x = DirectoryPinDecoratorRequest{}
mi := &file_v1_invoke_proto_msgTypes[23]
mi := &file_v1_invoke_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1488,7 +1586,7 @@ func (x *DirectoryPinDecoratorRequest) String() string {
func (*DirectoryPinDecoratorRequest) ProtoMessage() {}
func (x *DirectoryPinDecoratorRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[23]
mi := &file_v1_invoke_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1501,7 +1599,7 @@ func (x *DirectoryPinDecoratorRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPinDecoratorRequest.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorRequest) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{23}
return file_v1_invoke_proto_rawDescGZIP(), []int{25}
}
func (x *DirectoryPinDecoratorRequest) GetIndex() uint32 {
@ -1535,7 +1633,7 @@ type DirectoryPinDecoratorResponse struct {
func (x *DirectoryPinDecoratorResponse) Reset() {
*x = DirectoryPinDecoratorResponse{}
mi := &file_v1_invoke_proto_msgTypes[24]
mi := &file_v1_invoke_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1547,7 +1645,7 @@ func (x *DirectoryPinDecoratorResponse) String() string {
func (*DirectoryPinDecoratorResponse) ProtoMessage() {}
func (x *DirectoryPinDecoratorResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[24]
mi := &file_v1_invoke_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1560,7 +1658,7 @@ func (x *DirectoryPinDecoratorResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DirectoryPinDecoratorResponse.ProtoReflect.Descriptor instead.
func (*DirectoryPinDecoratorResponse) Descriptor() ([]byte, []int) {
return file_v1_invoke_proto_rawDescGZIP(), []int{24}
return file_v1_invoke_proto_rawDescGZIP(), []int{26}
}
func (x *DirectoryPinDecoratorResponse) GetPinJson() []byte {
@ -1588,7 +1686,7 @@ type ModerationDecisionEvent struct {
func (x *ModerationDecisionEvent) Reset() {
*x = ModerationDecisionEvent{}
mi := &file_v1_invoke_proto_msgTypes[25]
mi := &file_v1_invoke_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1600,7 +1698,7 @@ func (x *ModerationDecisionEvent) String() string {
func (*ModerationDecisionEvent) ProtoMessage() {}
func (x *ModerationDecisionEvent) ProtoReflect() protoreflect.Message {
mi := &file_v1_invoke_proto_msgTypes[25]
mi := &file_v1_invoke_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1613,7 +1711,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{25}
return file_v1_invoke_proto_rawDescGZIP(), []int{27}
}
func (x *ModerationDecisionEvent) GetMediaId() string {
@ -1707,7 +1805,10 @@ const file_v1_invoke_proto_rawDesc = "" +
"\aapp_url\x18\x01 \x01(\tR\x06appUrl\x12\x1d\n" +
"\n" +
"media_path\x18\x02 \x01(\tR\tmediaPath\"\x0e\n" +
"\fLoadResponse\"\x0f\n" +
"\fLoadResponse\"#\n" +
"\x0fLoadOnceRequest\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\"\x12\n" +
"\x10LoadOnceResponse\"\x0f\n" +
"\rUnloadRequest\"\x10\n" +
"\x0eUnloadResponse\"H\n" +
"\n" +
@ -1751,11 +1852,12 @@ const file_v1_invoke_proto_rawDesc = "" +
"paramsJson\"S\n" +
"\x12AiToolCallResponse\x12\x18\n" +
"\acontent\x18\x01 \x01(\tR\acontent\x12#\n" +
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"h\n" +
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x8d\x01\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" +
"\apayload\x18\x03 \x01(\fR\apayload\x12#\n" +
"\rcaller_plugin\x18\x04 \x01(\tR\fcallerPlugin\".\n" +
"\x12BridgeCallResponse\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
"\x1cDirectoryPanelSectionRequest\x12\x14\n" +
@ -1780,7 +1882,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*\xea\x02\n" +
"\x04note\x18\t \x01(\tR\x04note*\xfe\x02\n" +
"\x04Hook\x12\x14\n" +
"\x10HOOK_UNSPECIFIED\x10\x00\x12\x15\n" +
"\x11HOOK_RENDER_BLOCK\x10\x01\x12\x18\n" +
@ -1798,7 +1900,8 @@ const file_v1_invoke_proto_rawDesc = "" +
"\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*\x95\x02\n" +
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f\x12\x12\n" +
"\x0eHOOK_LOAD_ONCE\x10\x10*\x95\x02\n" +
"\fAbiErrorCode\x12\x1e\n" +
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
@ -1822,7 +1925,7 @@ 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, 26)
var file_v1_invoke_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_v1_invoke_proto_goTypes = []any{
(Hook)(0), // 0: abi.v1.Hook
(AbiErrorCode)(0), // 1: abi.v1.AbiErrorCode
@ -1834,34 +1937,36 @@ var file_v1_invoke_proto_goTypes = []any{
(*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
(*LoadOnceRequest)(nil), // 10: abi.v1.LoadOnceRequest
(*LoadOnceResponse)(nil), // 11: abi.v1.LoadOnceResponse
(*UnloadRequest)(nil), // 12: abi.v1.UnloadRequest
(*UnloadResponse)(nil), // 13: abi.v1.UnloadResponse
(*JobRequest)(nil), // 14: abi.v1.JobRequest
(*JobResponse)(nil), // 15: abi.v1.JobResponse
(*RagFetchRequest)(nil), // 16: abi.v1.RagFetchRequest
(*RagFetchResponse)(nil), // 17: abi.v1.RagFetchResponse
(*MediaHookRequest)(nil), // 18: abi.v1.MediaHookRequest
(*MediaHookResponse)(nil), // 19: abi.v1.MediaHookResponse
(*MediaAnalyzedEvent)(nil), // 20: abi.v1.MediaAnalyzedEvent
(*AiToolCallRequest)(nil), // 21: abi.v1.AiToolCallRequest
(*AiToolCallResponse)(nil), // 22: abi.v1.AiToolCallResponse
(*BridgeCallRequest)(nil), // 23: abi.v1.BridgeCallRequest
(*BridgeCallResponse)(nil), // 24: abi.v1.BridgeCallResponse
(*DirectoryPanelSectionRequest)(nil), // 25: abi.v1.DirectoryPanelSectionRequest
(*DirectoryPanelSectionResponse)(nil), // 26: abi.v1.DirectoryPanelSectionResponse
(*DirectoryPinDecoratorRequest)(nil), // 27: abi.v1.DirectoryPinDecoratorRequest
(*DirectoryPinDecoratorResponse)(nil), // 28: abi.v1.DirectoryPinDecoratorResponse
(*ModerationDecisionEvent)(nil), // 29: abi.v1.ModerationDecisionEvent
(*PluginManifest)(nil), // 30: 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
28, // 3: abi.v1.DescribeResponse.manifest:type_name -> abi.v1.PluginManifest
30, // 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
27, // 6: abi.v1.MediaHookRequest.moderation_decision:type_name -> abi.v1.ModerationDecisionEvent
20, // 5: abi.v1.MediaHookRequest.media_analyzed:type_name -> abi.v1.MediaAnalyzedEvent
29, // 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
@ -1875,7 +1980,7 @@ func file_v1_invoke_proto_init() {
return
}
file_v1_manifest_proto_init()
file_v1_invoke_proto_msgTypes[14].OneofWrappers = []any{
file_v1_invoke_proto_msgTypes[16].OneofWrappers = []any{
(*MediaHookRequest_MediaAnalyzed)(nil),
(*MediaHookRequest_ModerationDecision)(nil),
}
@ -1885,7 +1990,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: 26,
NumMessages: 28,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -156,7 +156,12 @@ type PluginManifest struct {
// duplicate schemas or procedure metadata by hand. The host still checks
// every procedure against rbac_method_roles before advertising or invoking
// it, so descriptor metadata cannot widen the plugin's authorization map.
McpTools []*MCPToolDescriptor `protobuf:"bytes,38,rep,name=mcp_tools,json=mcpTools,proto3" json:"mcp_tools,omitempty"`
McpTools []*MCPToolDescriptor `protobuf:"bytes,38,rep,name=mcp_tools,json=mcpTools,proto3" json:"mcp_tools,omitempty"`
// Stable keys for PluginRegistration.LoadOnce callbacks. The host persists
// successful (plugin,key) executions and invokes HOOK_LOAD_ONCE only for
// keys it has never completed. Changing a key is the explicit migration
// mechanism for running a replacement callback once.
LoadOnceKeys []string `protobuf:"bytes,39,rep,name=load_once_keys,json=loadOnceKeys,proto3" json:"load_once_keys,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -457,6 +462,13 @@ func (x *PluginManifest) GetMcpTools() []*MCPToolDescriptor {
return nil
}
func (x *PluginManifest) GetLoadOnceKeys() []string {
if x != nil {
return x.LoadOnceKeys
}
return nil
}
// MCPToolDescriptor is one unary ConnectRPC method prepared for the host MCP
// catalogue. Procedure, service, method, documentation, and the request JSON
// schema are all derived from protobuf reflection during DESCRIBE.
@ -1524,7 +1536,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
const file_v1_manifest_proto_rawDesc = "" +
"\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\xe7\x0e\n" +
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8d\x0f\n" +
"\x0ePluginManifest\x12\x1f\n" +
"\vabi_version\x18\x01 \x01(\rR\n" +
"abiVersion\x12\x12\n" +
@ -1567,7 +1579,8 @@ const file_v1_manifest_proto_rawDesc = "" +
"\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x128\n" +
"\rpublic_routes\x18$ \x03(\v2\x13.abi.v1.PublicRouteR\fpublicRoutes\x12\x18\n" +
"\asitemap\x18% \x01(\bR\asitemap\x126\n" +
"\tmcp_tools\x18& \x03(\v2\x19.abi.v1.MCPToolDescriptorR\bmcpTools\x1aB\n" +
"\tmcp_tools\x18& \x03(\v2\x19.abi.v1.MCPToolDescriptorR\bmcpTools\x12$\n" +
"\x0eload_once_keys\x18' \x03(\tR\floadOnceKeys\x1aB\n" +
"\x14RbacMethodRolesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf2\x01\n" +

View File

@ -161,7 +161,14 @@ type PoweredBlock struct {
// ninjatpl/pongo2 template source the host renders host-side.
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
// JSON encoding of the template data map (map[string]any).
DataJson []byte `protobuf:"bytes,2,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
DataJson []byte `protobuf:"bytes,2,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
// Requested HTTP response status. Before writing headers, the host accepts
// only 200599, uses the highest status class, and keeps the first result
// encountered within that class.
StatusCode int32 `protobuf:"varint,3,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
// Structured document metadata merged by the host into the page head. The
// first non-empty value for each field wins in deterministic render order.
Metadata *DocumentMetadata `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -210,6 +217,98 @@ func (x *PoweredBlock) GetDataJson() []byte {
return nil
}
func (x *PoweredBlock) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *PoweredBlock) GetMetadata() *DocumentMetadata {
if x != nil {
return x.Metadata
}
return nil
}
// DocumentMetadata is the safe, shared subset of document head data a plugin
// may provide. The host validates and escapes every value before rendering.
type DocumentMetadata struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
CanonicalUrl string `protobuf:"bytes,3,opt,name=canonical_url,json=canonicalUrl,proto3" json:"canonical_url,omitempty"`
ImageUrl string `protobuf:"bytes,4,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
Robots string `protobuf:"bytes,5,opt,name=robots,proto3" json:"robots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DocumentMetadata) Reset() {
*x = DocumentMetadata{}
mi := &file_v1_render_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DocumentMetadata) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DocumentMetadata) ProtoMessage() {}
func (x *DocumentMetadata) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[3]
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 DocumentMetadata.ProtoReflect.Descriptor instead.
func (*DocumentMetadata) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{3}
}
func (x *DocumentMetadata) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *DocumentMetadata) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *DocumentMetadata) GetCanonicalUrl() string {
if x != nil {
return x.CanonicalUrl
}
return ""
}
func (x *DocumentMetadata) GetImageUrl() string {
if x != nil {
return x.ImageUrl
}
return ""
}
func (x *DocumentMetadata) GetRobots() string {
if x != nil {
return x.Robots
}
return ""
}
// RenderTemplateRequest renders one template (templates.TemplateFunc).
type RenderTemplateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -223,7 +322,7 @@ type RenderTemplateRequest struct {
func (x *RenderTemplateRequest) Reset() {
*x = RenderTemplateRequest{}
mi := &file_v1_render_proto_msgTypes[3]
mi := &file_v1_render_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -235,7 +334,7 @@ func (x *RenderTemplateRequest) String() string {
func (*RenderTemplateRequest) ProtoMessage() {}
func (x *RenderTemplateRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[3]
mi := &file_v1_render_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -248,7 +347,7 @@ func (x *RenderTemplateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTemplateRequest.ProtoReflect.Descriptor instead.
func (*RenderTemplateRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{3}
return file_v1_render_proto_rawDescGZIP(), []int{4}
}
func (x *RenderTemplateRequest) GetTemplateKey() string {
@ -281,7 +380,7 @@ type RenderTemplateResponse struct {
func (x *RenderTemplateResponse) Reset() {
*x = RenderTemplateResponse{}
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -293,7 +392,7 @@ func (x *RenderTemplateResponse) String() string {
func (*RenderTemplateResponse) ProtoMessage() {}
func (x *RenderTemplateResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[4]
mi := &file_v1_render_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -306,7 +405,7 @@ func (x *RenderTemplateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTemplateResponse.ProtoReflect.Descriptor instead.
func (*RenderTemplateResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{4}
return file_v1_render_proto_rawDescGZIP(), []int{5}
}
func (x *RenderTemplateResponse) GetDocument() *TemplateDocument {
@ -329,14 +428,19 @@ type TemplateDocument struct {
BodyEndExtraHtml []byte `protobuf:"bytes,5,opt,name=body_end_extra_html,json=bodyEndExtraHtml,proto3" json:"body_end_extra_html,omitempty"` // appended before </body>, after bn.BodyEnd
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
// ignored — the lang field wins.
HtmlAttrs map[string]string `protobuf:"bytes,6,rep,name=html_attrs,json=htmlAttrs,proto3" json:"html_attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
HtmlAttrs map[string]string `protobuf:"bytes,6,rep,name=html_attrs,json=htmlAttrs,proto3" json:"html_attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
// Requested HTTP response status. The host applies the same validation and
// precedence rules as PoweredBlock.status_code.
StatusCode int32 `protobuf:"varint,7,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
// Structured document metadata merged by the host into the page head.
Metadata *DocumentMetadata `protobuf:"bytes,8,opt,name=metadata,proto3" json:"metadata,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TemplateDocument) Reset() {
*x = TemplateDocument{}
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -348,7 +452,7 @@ func (x *TemplateDocument) String() string {
func (*TemplateDocument) ProtoMessage() {}
func (x *TemplateDocument) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[5]
mi := &file_v1_render_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -361,7 +465,7 @@ func (x *TemplateDocument) ProtoReflect() protoreflect.Message {
// Deprecated: Use TemplateDocument.ProtoReflect.Descriptor instead.
func (*TemplateDocument) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{5}
return file_v1_render_proto_rawDescGZIP(), []int{6}
}
func (x *TemplateDocument) GetBodyHtml() []byte {
@ -406,6 +510,20 @@ func (x *TemplateDocument) GetHtmlAttrs() map[string]string {
return nil
}
func (x *TemplateDocument) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *TemplateDocument) GetMetadata() *DocumentMetadata {
if x != nil {
return x.Metadata
}
return nil
}
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
// The host wires one pongo2 tag per manifest.declared_tags name; when its
// engine hits that tag while rendering a powered block, it calls back here.
@ -424,7 +542,7 @@ type RenderTagRequest struct {
func (x *RenderTagRequest) Reset() {
*x = RenderTagRequest{}
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -436,7 +554,7 @@ func (x *RenderTagRequest) String() string {
func (*RenderTagRequest) ProtoMessage() {}
func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[6]
mi := &file_v1_render_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -449,7 +567,7 @@ func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTagRequest.ProtoReflect.Descriptor instead.
func (*RenderTagRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{6}
return file_v1_render_proto_rawDescGZIP(), []int{7}
}
func (x *RenderTagRequest) GetTagName() string {
@ -486,7 +604,7 @@ type RenderTagResponse struct {
func (x *RenderTagResponse) Reset() {
*x = RenderTagResponse{}
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -498,7 +616,7 @@ func (x *RenderTagResponse) String() string {
func (*RenderTagResponse) ProtoMessage() {}
func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[7]
mi := &file_v1_render_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -511,7 +629,7 @@ func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderTagResponse.ProtoReflect.Descriptor instead.
func (*RenderTagResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{7}
return file_v1_render_proto_rawDescGZIP(), []int{8}
}
func (x *RenderTagResponse) GetHtml() string {
@ -544,7 +662,7 @@ type ApplyFilterRequest struct {
func (x *ApplyFilterRequest) Reset() {
*x = ApplyFilterRequest{}
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -556,7 +674,7 @@ func (x *ApplyFilterRequest) String() string {
func (*ApplyFilterRequest) ProtoMessage() {}
func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[8]
mi := &file_v1_render_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -569,7 +687,7 @@ func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ApplyFilterRequest.ProtoReflect.Descriptor instead.
func (*ApplyFilterRequest) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{8}
return file_v1_render_proto_rawDescGZIP(), []int{9}
}
func (x *ApplyFilterRequest) GetFilterName() string {
@ -605,7 +723,7 @@ type ApplyFilterResponse struct {
func (x *ApplyFilterResponse) Reset() {
*x = ApplyFilterResponse{}
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -617,7 +735,7 @@ func (x *ApplyFilterResponse) String() string {
func (*ApplyFilterResponse) ProtoMessage() {}
func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[9]
mi := &file_v1_render_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -630,7 +748,7 @@ func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ApplyFilterResponse.ProtoReflect.Descriptor instead.
func (*ApplyFilterResponse) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{9}
return file_v1_render_proto_rawDescGZIP(), []int{10}
}
func (x *ApplyFilterResponse) GetOutput() string {
@ -694,7 +812,7 @@ type RenderContext struct {
func (x *RenderContext) Reset() {
*x = RenderContext{}
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -706,7 +824,7 @@ func (x *RenderContext) String() string {
func (*RenderContext) ProtoMessage() {}
func (x *RenderContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[10]
mi := &file_v1_render_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -719,7 +837,7 @@ func (x *RenderContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenderContext.ProtoReflect.Descriptor instead.
func (*RenderContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{10}
return file_v1_render_proto_rawDescGZIP(), []int{11}
}
func (x *RenderContext) GetRequest() *RequestInfo {
@ -868,7 +986,7 @@ type RequestInfo struct {
func (x *RequestInfo) Reset() {
*x = RequestInfo{}
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -880,7 +998,7 @@ func (x *RequestInfo) String() string {
func (*RequestInfo) ProtoMessage() {}
func (x *RequestInfo) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[11]
mi := &file_v1_render_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -893,7 +1011,7 @@ func (x *RequestInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestInfo.ProtoReflect.Descriptor instead.
func (*RequestInfo) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{11}
return file_v1_render_proto_rawDescGZIP(), []int{12}
}
func (x *RequestInfo) GetMethod() string {
@ -968,19 +1086,23 @@ func (x *RequestInfo) GetUserAgent() string {
// PageContext mirrors blocks.PageContext.
type PageContext struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
PostType string `protobuf:"bytes,4,opt,name=post_type,json=postType,proto3" json:"post_type,omitempty"` // "page", "post", "master", "system"
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "published", "draft", "scheduled"
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID
Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
PostType string `protobuf:"bytes,4,opt,name=post_type,json=postType,proto3" json:"post_type,omitempty"` // "page", "post", "master", "system"
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` // "published", "draft", "scheduled"
// Slug derived from the resolved page hierarchy.
DerivedSlug string `protobuf:"bytes,6,opt,name=derived_slug,json=derivedSlug,proto3" json:"derived_slug,omitempty"`
// Effective mount path after host inheritance and normalization.
MountPath string `protobuf:"bytes,7,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PageContext) Reset() {
*x = PageContext{}
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -992,7 +1114,7 @@ func (x *PageContext) String() string {
func (*PageContext) ProtoMessage() {}
func (x *PageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[12]
mi := &file_v1_render_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1005,7 +1127,7 @@ func (x *PageContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use PageContext.ProtoReflect.Descriptor instead.
func (*PageContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{12}
return file_v1_render_proto_rawDescGZIP(), []int{13}
}
func (x *PageContext) GetId() string {
@ -1043,6 +1165,20 @@ func (x *PageContext) GetStatus() string {
return ""
}
func (x *PageContext) GetDerivedSlug() string {
if x != nil {
return x.DerivedSlug
}
return ""
}
func (x *PageContext) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
// PostContext mirrors blocks.PostContext.
type PostContext struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1061,7 +1197,7 @@ type PostContext struct {
func (x *PostContext) Reset() {
*x = PostContext{}
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1073,7 +1209,7 @@ func (x *PostContext) String() string {
func (*PostContext) ProtoMessage() {}
func (x *PostContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[13]
mi := &file_v1_render_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1086,7 +1222,7 @@ func (x *PostContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use PostContext.ProtoReflect.Descriptor instead.
func (*PostContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{13}
return file_v1_render_proto_rawDescGZIP(), []int{14}
}
func (x *PostContext) GetId() string {
@ -1166,7 +1302,7 @@ type AuthorContext struct {
func (x *AuthorContext) Reset() {
*x = AuthorContext{}
mi := &file_v1_render_proto_msgTypes[14]
mi := &file_v1_render_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1178,7 +1314,7 @@ func (x *AuthorContext) String() string {
func (*AuthorContext) ProtoMessage() {}
func (x *AuthorContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[14]
mi := &file_v1_render_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1191,7 +1327,7 @@ func (x *AuthorContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use AuthorContext.ProtoReflect.Descriptor instead.
func (*AuthorContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{14}
return file_v1_render_proto_rawDescGZIP(), []int{15}
}
func (x *AuthorContext) GetId() string {
@ -1241,7 +1377,7 @@ type CategoryContext struct {
func (x *CategoryContext) Reset() {
*x = CategoryContext{}
mi := &file_v1_render_proto_msgTypes[15]
mi := &file_v1_render_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1253,7 +1389,7 @@ func (x *CategoryContext) String() string {
func (*CategoryContext) ProtoMessage() {}
func (x *CategoryContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[15]
mi := &file_v1_render_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1266,7 +1402,7 @@ func (x *CategoryContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use CategoryContext.ProtoReflect.Descriptor instead.
func (*CategoryContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{15}
return file_v1_render_proto_rawDescGZIP(), []int{16}
}
func (x *CategoryContext) GetId() string {
@ -1302,7 +1438,7 @@ type MasterPageContext struct {
func (x *MasterPageContext) Reset() {
*x = MasterPageContext{}
mi := &file_v1_render_proto_msgTypes[16]
mi := &file_v1_render_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1314,7 +1450,7 @@ func (x *MasterPageContext) String() string {
func (*MasterPageContext) ProtoMessage() {}
func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[16]
mi := &file_v1_render_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1327,7 +1463,7 @@ func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use MasterPageContext.ProtoReflect.Descriptor instead.
func (*MasterPageContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{16}
return file_v1_render_proto_rawDescGZIP(), []int{17}
}
func (x *MasterPageContext) GetId() string {
@ -1364,7 +1500,7 @@ type HumanProofBanner struct {
func (x *HumanProofBanner) Reset() {
*x = HumanProofBanner{}
mi := &file_v1_render_proto_msgTypes[17]
mi := &file_v1_render_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1376,7 +1512,7 @@ func (x *HumanProofBanner) String() string {
func (*HumanProofBanner) ProtoMessage() {}
func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[17]
mi := &file_v1_render_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1389,7 +1525,7 @@ func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
// Deprecated: Use HumanProofBanner.ProtoReflect.Descriptor instead.
func (*HumanProofBanner) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{17}
return file_v1_render_proto_rawDescGZIP(), []int{18}
}
func (x *HumanProofBanner) GetActiveTimeMinutes() int32 {
@ -1433,7 +1569,7 @@ type DetailRow struct {
func (x *DetailRow) Reset() {
*x = DetailRow{}
mi := &file_v1_render_proto_msgTypes[18]
mi := &file_v1_render_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1445,7 +1581,7 @@ func (x *DetailRow) String() string {
func (*DetailRow) ProtoMessage() {}
func (x *DetailRow) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[18]
mi := &file_v1_render_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1458,7 +1594,7 @@ func (x *DetailRow) ProtoReflect() protoreflect.Message {
// Deprecated: Use DetailRow.ProtoReflect.Descriptor instead.
func (*DetailRow) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{18}
return file_v1_render_proto_rawDescGZIP(), []int{19}
}
func (x *DetailRow) GetTableId() string {
@ -1524,13 +1660,17 @@ type BlockContext struct {
DetailRowDataJson []byte `protobuf:"bytes,36,opt,name=detail_row_data_json,json=detailRowDataJson,proto3" json:"detail_row_data_json,omitempty"`
BlogIndexUrl string `protobuf:"bytes,37,opt,name=blog_index_url,json=blogIndexUrl,proto3" json:"blog_index_url,omitempty"`
CategoryPageUrl string `protobuf:"bytes,38,opt,name=category_page_url,json=categoryPageUrl,proto3" json:"category_page_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Slug derived from the resolved page hierarchy.
DerivedSlug string `protobuf:"bytes,39,opt,name=derived_slug,json=derivedSlug,proto3" json:"derived_slug,omitempty"`
// Effective mount path after host inheritance and normalization.
MountPath string `protobuf:"bytes,40,opt,name=mount_path,json=mountPath,proto3" json:"mount_path,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BlockContext) Reset() {
*x = BlockContext{}
mi := &file_v1_render_proto_msgTypes[19]
mi := &file_v1_render_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1542,7 +1682,7 @@ func (x *BlockContext) String() string {
func (*BlockContext) ProtoMessage() {}
func (x *BlockContext) ProtoReflect() protoreflect.Message {
mi := &file_v1_render_proto_msgTypes[19]
mi := &file_v1_render_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1555,7 +1695,7 @@ func (x *BlockContext) ProtoReflect() protoreflect.Message {
// Deprecated: Use BlockContext.ProtoReflect.Descriptor instead.
func (*BlockContext) Descriptor() ([]byte, []int) {
return file_v1_render_proto_rawDescGZIP(), []int{19}
return file_v1_render_proto_rawDescGZIP(), []int{20}
}
func (x *BlockContext) GetUrl() string {
@ -1824,6 +1964,20 @@ func (x *BlockContext) GetCategoryPageUrl() string {
return ""
}
func (x *BlockContext) GetDerivedSlug() string {
if x != nil {
return x.DerivedSlug
}
return ""
}
func (x *BlockContext) GetMountPath() string {
if x != nil {
return x.MountPath
}
return ""
}
var File_v1_render_proto protoreflect.FileDescriptor
const file_v1_render_proto_rawDesc = "" +
@ -1835,16 +1989,25 @@ const file_v1_render_proto_rawDesc = "" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"Y\n" +
"\x13RenderBlockResponse\x12\x12\n" +
"\x04html\x18\x01 \x01(\tR\x04html\x12.\n" +
"\apowered\x18\x02 \x01(\v2\x14.abi.v1.PoweredBlockR\apowered\"G\n" +
"\apowered\x18\x02 \x01(\v2\x14.abi.v1.PoweredBlockR\apowered\"\x9e\x01\n" +
"\fPoweredBlock\x12\x1a\n" +
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1b\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\"\x93\x01\n" +
"\tdata_json\x18\x02 \x01(\fR\bdataJson\x12\x1f\n" +
"\vstatus_code\x18\x03 \x01(\x05R\n" +
"statusCode\x124\n" +
"\bmetadata\x18\x04 \x01(\v2\x18.abi.v1.DocumentMetadataR\bmetadata\"\xa4\x01\n" +
"\x10DocumentMetadata\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12#\n" +
"\rcanonical_url\x18\x03 \x01(\tR\fcanonicalUrl\x12\x1b\n" +
"\timage_url\x18\x04 \x01(\tR\bimageUrl\x12\x16\n" +
"\x06robots\x18\x05 \x01(\tR\x06robots\"\x93\x01\n" +
"\x15RenderTemplateRequest\x12!\n" +
"\ftemplate_key\x18\x01 \x01(\tR\vtemplateKey\x12\x19\n" +
"\bdoc_json\x18\x02 \x01(\fR\adocJson\x12<\n" +
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"T\n" +
"\x16RenderTemplateResponse\x124\n" +
"\bdocument\x18\x02 \x01(\v2\x18.abi.v1.TemplateDocumentR\bdocumentJ\x04\b\x01\x10\x02\"\xbf\x02\n" +
"\bdocument\x18\x02 \x01(\v2\x18.abi.v1.TemplateDocumentR\bdocumentJ\x04\b\x01\x10\x02\"\x96\x03\n" +
"\x10TemplateDocument\x12\x1b\n" +
"\tbody_html\x18\x01 \x01(\fR\bbodyHtml\x12\x1d\n" +
"\n" +
@ -1853,7 +2016,10 @@ const file_v1_render_proto_rawDesc = "" +
"\x0fhead_extra_html\x18\x04 \x01(\fR\rheadExtraHtml\x12-\n" +
"\x13body_end_extra_html\x18\x05 \x01(\fR\x10bodyEndExtraHtml\x12F\n" +
"\n" +
"html_attrs\x18\x06 \x03(\v2'.abi.v1.TemplateDocument.HtmlAttrsEntryR\thtmlAttrs\x1a<\n" +
"html_attrs\x18\x06 \x03(\v2'.abi.v1.TemplateDocument.HtmlAttrsEntryR\thtmlAttrs\x12\x1f\n" +
"\vstatus_code\x18\a \x01(\x05R\n" +
"statusCode\x124\n" +
"\bmetadata\x18\b \x01(\v2\x18.abi.v1.DocumentMetadataR\bmetadata\x1a<\n" +
"\x0eHtmlAttrsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x88\x01\n" +
@ -1916,13 +2082,16 @@ const file_v1_render_proto_rawDesc = "" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a:\n" +
"\fCookiesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"|\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xbe\x01\n" +
"\vPageContext\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04slug\x18\x02 \x01(\tR\x04slug\x12\x14\n" +
"\x05title\x18\x03 \x01(\tR\x05title\x12\x1b\n" +
"\tpost_type\x18\x04 \x01(\tR\bpostType\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\"\xaf\x02\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12!\n" +
"\fderived_slug\x18\x06 \x01(\tR\vderivedSlug\x12\x1d\n" +
"\n" +
"mount_path\x18\a \x01(\tR\tmountPath\"\xaf\x02\n" +
"\vPostContext\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04slug\x18\x02 \x01(\tR\x04slug\x12\x14\n" +
@ -1957,7 +2126,7 @@ const file_v1_render_proto_rawDesc = "" +
"\tDetailRow\x12\x19\n" +
"\btable_id\x18\x01 \x01(\tR\atableId\x12\x15\n" +
"\x06row_id\x18\x02 \x01(\tR\x05rowId\x12\x1b\n" +
"\tdata_json\x18\x03 \x01(\fR\bdataJson\"\x85\f\n" +
"\tdata_json\x18\x03 \x01(\fR\bdataJson\"\xc7\f\n" +
"\fBlockContext\x12\x10\n" +
"\x03url\x18\x01 \x01(\tR\x03url\x12\x12\n" +
"\x04path\x18\x02 \x01(\tR\x04path\x12\x12\n" +
@ -2001,7 +2170,10 @@ const file_v1_render_proto_rawDesc = "" +
"\x0fdetail_table_id\x18# \x01(\tR\rdetailTableId\x12/\n" +
"\x14detail_row_data_json\x18$ \x01(\fR\x11detailRowDataJson\x12$\n" +
"\x0eblog_index_url\x18% \x01(\tR\fblogIndexUrl\x12*\n" +
"\x11category_page_url\x18& \x01(\tR\x0fcategoryPageUrl\x1a8\n" +
"\x11category_page_url\x18& \x01(\tR\x0fcategoryPageUrl\x12!\n" +
"\fderived_slug\x18' \x01(\tR\vderivedSlug\x12\x1d\n" +
"\n" +
"mount_path\x18( \x01(\tR\tmountPath\x1a8\n" +
"\n" +
"QueryEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
@ -2025,66 +2197,69 @@ func file_v1_render_proto_rawDescGZIP() []byte {
return file_v1_render_proto_rawDescData
}
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_v1_render_proto_goTypes = []any{
(*RenderBlockRequest)(nil), // 0: abi.v1.RenderBlockRequest
(*RenderBlockResponse)(nil), // 1: abi.v1.RenderBlockResponse
(*PoweredBlock)(nil), // 2: abi.v1.PoweredBlock
(*RenderTemplateRequest)(nil), // 3: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 4: abi.v1.RenderTemplateResponse
(*TemplateDocument)(nil), // 5: abi.v1.TemplateDocument
(*RenderTagRequest)(nil), // 6: abi.v1.RenderTagRequest
(*RenderTagResponse)(nil), // 7: abi.v1.RenderTagResponse
(*ApplyFilterRequest)(nil), // 8: abi.v1.ApplyFilterRequest
(*ApplyFilterResponse)(nil), // 9: abi.v1.ApplyFilterResponse
(*RenderContext)(nil), // 10: abi.v1.RenderContext
(*RequestInfo)(nil), // 11: abi.v1.RequestInfo
(*PageContext)(nil), // 12: abi.v1.PageContext
(*PostContext)(nil), // 13: abi.v1.PostContext
(*AuthorContext)(nil), // 14: abi.v1.AuthorContext
(*CategoryContext)(nil), // 15: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 16: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 17: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 18: abi.v1.DetailRow
(*BlockContext)(nil), // 19: abi.v1.BlockContext
nil, // 20: abi.v1.TemplateDocument.HtmlAttrsEntry
nil, // 21: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 22: abi.v1.RequestInfo.HeadersEntry
nil, // 23: abi.v1.RequestInfo.CookiesEntry
nil, // 24: abi.v1.BlockContext.QueryEntry
nil, // 25: abi.v1.BlockContext.CookiesEntry
nil, // 26: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp
(*DocumentMetadata)(nil), // 3: abi.v1.DocumentMetadata
(*RenderTemplateRequest)(nil), // 4: abi.v1.RenderTemplateRequest
(*RenderTemplateResponse)(nil), // 5: abi.v1.RenderTemplateResponse
(*TemplateDocument)(nil), // 6: abi.v1.TemplateDocument
(*RenderTagRequest)(nil), // 7: abi.v1.RenderTagRequest
(*RenderTagResponse)(nil), // 8: abi.v1.RenderTagResponse
(*ApplyFilterRequest)(nil), // 9: abi.v1.ApplyFilterRequest
(*ApplyFilterResponse)(nil), // 10: abi.v1.ApplyFilterResponse
(*RenderContext)(nil), // 11: abi.v1.RenderContext
(*RequestInfo)(nil), // 12: abi.v1.RequestInfo
(*PageContext)(nil), // 13: abi.v1.PageContext
(*PostContext)(nil), // 14: abi.v1.PostContext
(*AuthorContext)(nil), // 15: abi.v1.AuthorContext
(*CategoryContext)(nil), // 16: abi.v1.CategoryContext
(*MasterPageContext)(nil), // 17: abi.v1.MasterPageContext
(*HumanProofBanner)(nil), // 18: abi.v1.HumanProofBanner
(*DetailRow)(nil), // 19: abi.v1.DetailRow
(*BlockContext)(nil), // 20: abi.v1.BlockContext
nil, // 21: abi.v1.TemplateDocument.HtmlAttrsEntry
nil, // 22: abi.v1.RenderContext.InjectedSlotsEntry
nil, // 23: abi.v1.RequestInfo.HeadersEntry
nil, // 24: abi.v1.RequestInfo.CookiesEntry
nil, // 25: abi.v1.BlockContext.QueryEntry
nil, // 26: abi.v1.BlockContext.CookiesEntry
nil, // 27: abi.v1.BlockContext.HeadersEntry
(*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp
}
var file_v1_render_proto_depIdxs = []int32{
10, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
11, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
2, // 1: abi.v1.RenderBlockResponse.powered:type_name -> abi.v1.PoweredBlock
10, // 2: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
5, // 3: abi.v1.RenderTemplateResponse.document:type_name -> abi.v1.TemplateDocument
20, // 4: abi.v1.TemplateDocument.html_attrs:type_name -> abi.v1.TemplateDocument.HtmlAttrsEntry
10, // 5: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
11, // 6: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
19, // 7: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
12, // 8: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
13, // 9: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
14, // 10: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
15, // 11: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
16, // 12: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
21, // 13: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
17, // 14: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
18, // 15: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
22, // 16: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
23, // 17: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
27, // 18: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
27, // 19: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
24, // 20: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
25, // 21: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
26, // 22: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
23, // [23:23] is the sub-list for method output_type
23, // [23:23] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
3, // 2: abi.v1.PoweredBlock.metadata:type_name -> abi.v1.DocumentMetadata
11, // 3: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
6, // 4: abi.v1.RenderTemplateResponse.document:type_name -> abi.v1.TemplateDocument
21, // 5: abi.v1.TemplateDocument.html_attrs:type_name -> abi.v1.TemplateDocument.HtmlAttrsEntry
3, // 6: abi.v1.TemplateDocument.metadata:type_name -> abi.v1.DocumentMetadata
11, // 7: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
12, // 8: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
20, // 9: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
13, // 10: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
14, // 11: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
15, // 12: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
16, // 13: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
17, // 14: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
22, // 15: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
18, // 16: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
19, // 17: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
23, // 18: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
24, // 19: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
28, // 20: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
28, // 21: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
25, // 22: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
26, // 23: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
27, // 24: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
25, // [25:25] is the sub-list for method output_type
25, // [25:25] is the sub-list for method input_type
25, // [25:25] is the sub-list for extension type_name
25, // [25:25] is the sub-list for extension extendee
0, // [0:25] is the sub-list for field type_name
}
func init() { file_v1_render_proto_init() }
@ -2098,7 +2273,7 @@ func file_v1_render_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_render_proto_rawDesc), len(file_v1_render_proto_rawDesc)),
NumEnums: 0,
NumMessages: 27,
NumMessages: 28,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/google/uuid"
@ -97,10 +98,8 @@ func TestCaptchaVerifiedReadsTrustedHeader(t *testing.T) {
}
func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) {
for _, h := range AllTrustedHeaders() {
if h == HeaderVerifiedCaptcha {
return
}
if slices.Contains(AllTrustedHeaders(), HeaderVerifiedCaptcha) {
return
}
t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha)
}

View File

@ -13,11 +13,13 @@ import (
// PageContext provides page information to blocks via context.
// The CMS populates this from its internal db.Page type.
type PageContext struct {
ID uuid.UUID
Slug string
Title string
PostType string // "page", "post", "master", "system"
Status string // "published", "draft", "scheduled"
ID uuid.UUID
Slug string
DerivedSlug string // resolved route from the page hierarchy
MountPath string // effective host-resolved mount prefix
Title string
PostType string // "page", "post", "master", "system"
Status string // "published", "draft", "scheduled"
}
// PostContext provides blog post information to blocks via context.
@ -368,44 +370,46 @@ func RenderSlot(ctx context.Context) string {
// BlockContext provides contextual information available to all block templates.
type BlockContext struct {
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
URL string `pongo2:"url"`
Path string `pongo2:"path"`
Slug string `pongo2:"slug"`
DerivedSlug string `pongo2:"derivedSlug"`
MountPath string `pongo2:"mountPath"`
PageID string `pongo2:"pageId"`
PageTitle string `pongo2:"pageTitle"`
TemplateKey string `pongo2:"templateKey"`
IsEditor bool `pongo2:"isEditor"`
Timestamp int64 `pongo2:"timestamp"`
IsLoggedIn bool `pongo2:"isLoggedIn"`
UserID string `pongo2:"userId"`
UserEmail string `pongo2:"userEmail"`
UserRole string `pongo2:"userRole"`
Method string `pongo2:"method"`
Host string `pongo2:"host"`
Query map[string]string `pongo2:"query"`
Referrer string `pongo2:"referrer"`
UserAgent string `pongo2:"userAgent"`
IP string `pongo2:"ip"`
Cookies map[string]string `pongo2:"cookies"`
Headers map[string]string `pongo2:"headers"`
Country string `pongo2:"country"`
City string `pongo2:"city"`
Timezone string `pongo2:"timezone"`
Now time.Time `pongo2:"now"`
CurrentAuthor map[string]any `pongo2:"currentAuthor"`
CurrentPost map[string]any `pongo2:"currentPost"`
CurrentCategory map[string]any `pongo2:"currentCategory"`
Site map[string]any `pongo2:"site"`
IsPublicLoggedIn bool `pongo2:"isPublicLoggedIn"`
PublicUserID string `pongo2:"publicUserId"`
PublicUsername string `pongo2:"publicUsername"`
PublicDisplayName string `pongo2:"publicDisplayName"`
PublicEmailVerified bool `pongo2:"publicEmailVerified"`
DetailRowID string `pongo2:"detailRowId"`
DetailTableID string `pongo2:"detailTableId"`
DetailRowData map[string]any `pongo2:"detailRowData"`
BlogIndexURL string `pongo2:"blogIndexUrl"`
CategoryPageURL string `pongo2:"categoryPageUrl"`
}
type blockContextKey struct{}
@ -425,6 +429,7 @@ func GetBlockContext(ctx context.Context) *BlockContext {
func (bc *BlockContext) ToMap() map[string]any {
return map[string]any{
"url": bc.URL, "path": bc.Path, "slug": bc.Slug,
"derivedSlug": bc.DerivedSlug, "mountPath": bc.MountPath,
"pageId": bc.PageID, "pageTitle": bc.PageTitle,
"templateKey": bc.TemplateKey, "isEditor": bc.IsEditor,
"timestamp": bc.Timestamp, "now": bc.Now,
@ -437,12 +442,12 @@ func (bc *BlockContext) ToMap() map[string]any {
"currentAuthor": bc.CurrentAuthor, "currentPost": bc.CurrentPost,
"currentCategory": bc.CurrentCategory, "site": bc.Site,
"isPublicLoggedIn": bc.IsPublicLoggedIn,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicUserId": bc.PublicUserID, "publicUsername": bc.PublicUsername,
"publicDisplayName": bc.PublicDisplayName,
"publicEmailVerified": bc.PublicEmailVerified,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowId": bc.DetailRowID, "detailTableId": bc.DetailTableID,
"detailRowData": bc.DetailRowData,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
"blogIndexUrl": bc.BlogIndexURL, "categoryPageUrl": bc.CategoryPageURL,
}
}

View File

@ -3,6 +3,8 @@ package blocks
import (
"encoding/json"
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
// poweredBlockSentinel prefixes a PoweredBlock marker string. It uses NUL
@ -16,8 +18,17 @@ const poweredBlockSentinel = "\x00bn:powered\x00"
// and forwards it as abiv1.PoweredBlock so the HOST renders the template with
// pongo2 — after the block-invoke has returned, keeping the guest free.
type PoweredResult struct {
Template string `json:"template"`
Data map[string]any `json:"data"`
Template string `json:"template"`
Data map[string]any `json:"data"`
StatusCode int32 `json:"statusCode,omitempty"`
Metadata *document.Metadata `json:"metadata,omitempty"`
}
// PoweredOptions controls host-owned response data for a powered block.
// StatusCode is transported as declared; the host accepts only 200599.
type PoweredOptions struct {
StatusCode int32
Metadata *document.Metadata
}
// PoweredBlock marks a block's return value as "powered": instead of final
@ -37,11 +48,28 @@ type PoweredResult struct {
// hits ({% mytag %} / |myfilter) is a fresh RENDER_TAG / APPLY_FILTER invoke,
// never a re-entrant one.
func PoweredBlock(template string, data map[string]any) string {
payload, err := json.Marshal(PoweredResult{Template: template, Data: data})
return poweredBlockMarker(PoweredResult{Template: template, Data: data})
}
// PoweredBlockWithOptions is PoweredBlock with structured document metadata
// and an optional HTTP response status for the host to accumulate before it
// writes response headers.
func PoweredBlockWithOptions(template string, data map[string]any, options PoweredOptions) string {
return poweredBlockMarker(PoweredResult{
Template: template,
Data: data,
StatusCode: options.StatusCode,
Metadata: options.Metadata,
})
}
func poweredBlockMarker(result PoweredResult) string {
payload, err := json.Marshal(result)
if err != nil {
// A non-serializable data map is a programming error; fall back to an
// empty-data powered result so the template still renders.
payload, _ = json.Marshal(PoweredResult{Template: template})
// empty-data result while preserving response options.
result.Data = nil
payload, _ = json.Marshal(result)
}
return poweredBlockSentinel + string(payload)
}

View File

@ -1,6 +1,10 @@
package blocks
import "testing"
import (
"testing"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
func TestPoweredBlockRoundTrip(t *testing.T) {
marker := PoweredBlock("<p>{{ x }}</p>", map[string]any{"x": "y"})
@ -16,6 +20,47 @@ func TestPoweredBlockRoundTrip(t *testing.T) {
}
}
func TestPoweredBlockWithOptionsRoundTrip(t *testing.T) {
metadata := &document.Metadata{
Title: "Missing article",
Description: "The requested article was not found.",
CanonicalURL: "https://example.com/docs/missing",
ImageURL: "https://example.com/media/missing.png",
Robots: "noindex,nofollow",
}
marker := PoweredBlockWithOptions("<h1>{{ title }}</h1>", map[string]any{"title": "Missing"}, PoweredOptions{
StatusCode: 404,
Metadata: metadata,
})
got, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize its own marker")
}
if got.StatusCode != 404 {
t.Errorf("status code = %d, want 404", got.StatusCode)
}
if got.Metadata == nil || *got.Metadata != *metadata {
t.Errorf("metadata = %+v, want %+v", got.Metadata, metadata)
}
}
func TestPoweredBlockWithOptionsPreservesOptionsWhenDataCannotMarshal(t *testing.T) {
marker := PoweredBlockWithOptions("<h1>fallback</h1>", map[string]any{"bad": make(chan int)}, PoweredOptions{
StatusCode: 503,
Metadata: &document.Metadata{Title: "Unavailable"},
})
got, ok := DecodePoweredBlock(marker)
if !ok {
t.Fatal("DecodePoweredBlock did not recognize fallback marker")
}
if got.Data != nil {
t.Errorf("data = %#v, want nil fallback", got.Data)
}
if got.StatusCode != 503 || got.Metadata == nil || got.Metadata.Title != "Unavailable" {
t.Errorf("options were not preserved: %+v", got)
}
}
func TestDecodePoweredBlockRejectsPlainHTML(t *testing.T) {
if _, ok := DecodePoweredBlock("<p>hello</p>"); ok {
t.Error("plain HTML must not decode as a powered block")

View File

@ -0,0 +1,40 @@
# Keyed load-once hooks have durable host receipts
Plugin `Load` callbacks are deliberately repeatable: startup, enable, update,
and provisioning-consent replay can all invoke them. Some initialization is
different. A plugin may add a suggested menu link on its first load, but must
not restore that link after an administrator later removes or renames it.
Decision: `PluginRegistration.LoadOnce` is an ordered slice of callbacks with
stable keys. DESCRIBE publishes those keys in `PluginManifest.load_once_keys`.
The host checks a durable `(plugin, key)` receipt before dispatching
`HOOK_LOAD_ONCE`; it records the receipt only after success. A completed key is
not removed when the plugin is disabled, updated, or uninstalled. A later
release that intentionally needs another one-time action declares a new key.
Load-once runs after the ordinary `Load` hook so repeatable provisioning can
create resources that a one-time callback references. Keys are lowercase,
MCP-style stable identifiers of at most 128 bytes and must be unique within a
registration. Failed callbacks may be retried because no completion receipt
exists. As with database migrations, callbacks should still make their own
external mutation idempotent to cover a process failure between the mutation
and receipt commit.
Encoding first-install booleans in plugin settings was rejected because it
mixes host lifecycle state with customer-visible configuration. Making all
provisioner operations one-shot was rejected because pages, settings defaults,
and other declarations intentionally reconcile on every load. Version-based
execution was rejected because an unrelated release must not repeat the side
effect.
Consequences:
- Native and WASM plugins share one keyed lifecycle abstraction.
- The host, not guest memory, owns durable completion state.
- Updating or re-enabling a plugin cannot recreate an administrator-removed
resource under an already completed key.
- Changing a key is an explicit request to run a new callback once.
Keywords: PluginRegistration.LoadOnce, LoadOnceHook, load_once_keys,
HOOK_LOAD_ONCE, durable receipt, first load, plugin update, idempotency,
one-time provisioning, lifecycle

View File

@ -0,0 +1,58 @@
# Provisioned pages use stable keys and render results carry safe response data
Provisioned pages were identified only by slug. Slugs are customer-facing
content and can change when an administrator renames a page, changes its
hierarchy, or mounts a plugin below a customer-specific route. A later plugin
load could therefore fail to find its page, recreate it, or attach children
and menu items to a stale location.
Decision: a page declaration may carry a plugin-scoped `Key`. `ParentKey`
references another declaration from the same plugin, while menu items use the
pair `PagePlugin` and `PageKey` so they can target a provisioned page across
plugin boundaries. The existing `ParentSlug` field remains an explicit legacy
input, but keyed references are the stable identity for new declarations.
`MountPath` is create-only. The host applies it while creating the page but
does not relocate an existing customer page during later reconciliation.
`MergeMissingBlockContent` enables a shallow default merge for matching
plugin-owned block assignments: only absent top-level keys are added, and
present values such as an empty string, zero, false, or null are preserved.
The render ABI exposes both `DerivedSlug` and `MountPath` on page and block
contexts. `DerivedSlug` is computed from the resolved hierarchy. `MountPath`
is the effective, normalized value after host inheritance, rather than the raw
stored declaration. Guests consume these values and do not reimplement host
routing rules.
Powered blocks and template documents may also request an HTTP status and a
shared `DocumentMetadata` value. Metadata is deliberately limited to title,
description, canonical URL, image URL, and robots directives. The host
validates status codes and metadata, escapes values, and accumulates status
before writing headers. Only 200599 is accepted; the highest status class
wins, with the first result retained within a class. For metadata, the first
non-empty value for each field wins in deterministic render order, after which
page/document defaults fill gaps. Arbitrary meta/link maps were rejected
because they would create a second unvalidated head-injection path; advanced
trusted templates already have the explicit `HeadExtra` surface.
Appending fields to the v1 protobuf messages was chosen over renumbering or a
Go-only helper because `abi/proto/v1` is the language-neutral contract. Slug
fallbacks remain data in that contract; the SDK does not silently synthesize
keys from mutable slugs.
Consequences:
- Plugin updates can find the same provisioned page after customer route
changes without taking ownership of the customer's chosen slug.
- Parent and menu references survive page moves and renames.
- Changing a declaration's mount path does not unexpectedly move a live page.
- Plugins can add newly introduced block defaults without overwriting customer
configuration.
- Native and WASM render code receive the same host-resolved routing values.
- Wiki-like powered blocks can return correct not-found status and structured
metadata without rendering or injecting the document envelope themselves.
Keywords: PageConfig.Key, ParentKey, ParentSlug, MountPath,
MergeMissingBlockContent, MenuItemConfig.PagePlugin, PageKey, DerivedSlug,
PageSeed, RenderContext, PoweredBlockWithOptions, DocumentMetadata,
TemplateDocument, stable identity, page provisioning

View File

@ -0,0 +1,37 @@
# Plugin manifests declare admin API compatibility
Plugin admin bundles consume the versioned `@block-ninja/api` browser package.
The existing `block_core` compatibility constraint describes the host plugin
runtime, but it cannot tell a registry or CMS whether an admin bundle is safe
to load against the host's browser API. Treating those surfaces as one version
would couple independent release cycles and allow an otherwise compatible
plugin to fail only after its admin page loads.
Decision: `[compatibility]` gains an optional `admin_api` string. Its value is
a semantic-version constraint for the host-provided browser admin API, for
example `admin_api = ">=0.1.2"`. The manifest/parser layer preserves the value
verbatim; registry and host resolvers own constraint validation and matching.
An omitted or empty value means the plugin has not declared browser API
compatibility. It is not equivalent to an unconstrained wildcard. Automated
latest-compatible resolution that filters by a host admin API version must
therefore fail closed for an undeclared value. This does not change the
separate `block_core` constraint or imply that every plugin has an admin
bundle.
The field is part of the shared manifest model and the CLI's hand-written
`plugin.mod` serializer so version bumps, tag edits, and initialization cannot
silently discard it.
Consequences:
- Registries and hosts can select plugin releases compatible with both the
runtime ABI and browser admin API.
- Headless plugins can continue to omit `admin_api`.
- Plugin authors express a range rather than pinning a single browser package
version.
- Constraint syntax and matching remain the resolver's responsibility rather
than being duplicated in manifest parsers.
Keywords: plugin.mod, compatibility, admin_api, AdminAPI, @block-ninja/api,
semantic version, browser API, fail closed, plugin resolver

View File

@ -0,0 +1,27 @@
# Plugin manifests declare Plugin SDK compatibility
The historical `block_core` compatibility field predates the standalone
Plugin SDK and is ambiguous: CMS core and the plugin-facing SDK have separate
release cycles. Interpreting the same constraint against both versions can
select an artifact that the host cannot load or reject one that is compatible.
Decision: `[compatibility]` gains an optional `plugin_sdk` string containing a
semantic-version constraint for the plugin-facing SDK/ABI, for example
`plugin_sdk = ">=0.3.7"`. Manifest parsing preserves the value verbatim;
registry and host resolvers own constraint validation and matching.
An omitted or empty value is undeclared compatibility, not a wildcard.
Automated resolution against a host SDK version must fail closed, except for
an explicitly bounded migration policy for releases published before this
field existed. `block_core` remains a separate historical constraint and is
not reinterpreted as the CMS application's own version.
Consequences:
- Plugins can state the actual SDK/ABI range they require.
- CMS core can evolve independently of the public Plugin SDK.
- New releases must declare `plugin_sdk` to participate in automatic
SDK-compatible selection.
Keywords: plugin.mod, compatibility, plugin_sdk, PluginSDK, ABI, semantic
version, plugin resolver, fail closed

View File

@ -0,0 +1,27 @@
# Bridge providers receive authenticated caller identity
Bridge payloads are guest-controlled. A provider that uses a payload field as
an ownership namespace lets one plugin impersonate another plugin and mutate
or claim its managed records. The consumer-to-host `BridgeInvokeRequest`
cannot safely carry identity because the consumer constructs that message.
Decision: the host derives the caller plugin from the loaded module or native
plugin registration. It passes that identity to the provider in
`BridgeCallRequest.caller_plugin` and, for Go providers, through
`plugin.WithBridgeCallerPlugin`. Providers read it with
`plugin.BridgeCallerPlugin` and fail closed when an ownership-sensitive call
has no authenticated caller.
The new host-to-provider protobuf field is additive. Older providers ignore
it. New ownership-sensitive providers intentionally reject calls from older
hosts that cannot authenticate a caller. Hosts must never copy a caller name
from opaque bridge payloads or a consumer-authored capability field.
Consequences:
- Providers can derive durable ownership from an authenticated principal.
- Bridge payload schemas do not need security-sensitive source fields.
- Native and Wasm providers observe the same caller context contract.
Keywords: plugin bridge, caller identity, authentication, ownership,
BridgeCallRequest, WithBridgeCallerPlugin, Wasm, confused deputy

View File

@ -0,0 +1,36 @@
# Register sees the current guest host services
Go guest plugins can declare capability-backed runtime surfaces from their
`Register` callback. Bridge providers are the first such surface: the provider
stores its invokable value in the guest-local bridge stub while the stub tells
the CMS host that the named service exists.
`Serve` previously assigned the package-level guest runtime only after
`newGuest` returned. Because `newGuest` runs `Register` synchronously,
`HostServices()` returned an empty `CoreServices` value during registration.
Plugins that correctly nil-checked the bridge silently skipped registration.
The CMS could therefore mark a Wasm plugin loaded while consumers failed with
`bridge: no service registered` during a later load hook.
`newGuest` now makes the guest under construction current for the synchronous
registration pass and restores the previous runtime before returning. `Serve`
then installs the completed guest as before. This keeps construction isolated
for native tests while making the documented `HostServices()` escape hatch
truthful during `Register` on every pooled Wasm instance.
Alternatives rejected were moving bridge registration into `Load`, which runs
on only one pooled instance, and adding bridge names only to the manifest,
which would advertise host availability without installing the guest-local
`BridgeInvokable` value needed by `HOOK_BRIDGE_CALL`.
Consequences:
- Bridge services registered from `Register` exist on every pooled guest
instance and remain callable after CMS startup and hot swap.
- A regression test exercises the observable `Serve` plus
`HOOK_BRIDGE_CALL` contract instead of relying on initialization internals.
- No ABI or protobuf change is required; consumers need a plugin SDK release
containing the corrected Go guest runtime.
Keywords: wasmguest.Serve, newGuest, HostServices, PluginRegistration.Register,
plugin bridge, RegisterService, BridgeInvokable, HOOK_BRIDGE_CALL, wiki content

14
document/metadata.go Normal file
View File

@ -0,0 +1,14 @@
// Package document defines structured page-response metadata shared by block
// and template render results.
package document
// Metadata is the safe document-head subset a plugin may ask the host to
// render. The host remains responsible for validating URLs and robots values
// and for escaping every value before it is written into HTML.
type Metadata struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
CanonicalURL string `json:"canonicalUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Robots string `json:"robots,omitempty"`
}

View File

@ -166,12 +166,12 @@ func validHostname(h string) bool {
if len(h) > 253 {
return false
}
for _, label := range strings.Split(h, ".") {
for label := range strings.SplitSeq(h, ".") {
if label == "" || len(label) > 63 {
return false
}
for _, r := range label {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
return false
}
}

View File

@ -2,6 +2,26 @@ package plugin
import "context"
type bridgeCallerPluginContextKey struct{}
// WithBridgeCallerPlugin records the host-authenticated plugin name of a
// bridge caller. Hosts and ABI adapters must derive pluginName from the loaded
// caller, never from guest-controlled request data.
func WithBridgeCallerPlugin(ctx context.Context, pluginName string) context.Context {
return context.WithValue(ctx, bridgeCallerPluginContextKey{}, pluginName)
}
// BridgeCallerPlugin returns the host-authenticated plugin name of the
// current bridge caller. Ownership-sensitive providers should fail closed
// when no caller identity is present.
func BridgeCallerPlugin(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
pluginName, ok := ctx.Value(bridgeCallerPluginContextKey{}).(string)
return pluginName, ok && pluginName != ""
}
// PluginBridge allows plugins to share services with each other.
// Plugins register named services during startup; other plugins look them up at runtime.
//

25
plugin/bridge_test.go Normal file
View File

@ -0,0 +1,25 @@
package plugin
import (
"context"
"testing"
)
func TestBridgeCallerPlugin(t *testing.T) {
if got, ok := BridgeCallerPlugin(context.Background()); ok || got != "" {
t.Fatalf("BridgeCallerPlugin(background) = %q, %t, want empty, false", got, ok)
}
ctx := WithBridgeCallerPlugin(context.Background(), "website")
if got, ok := BridgeCallerPlugin(ctx); !ok || got != "website" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", got, ok)
}
}
func TestWithBridgeCallerPluginEmptyDoesNotAuthenticateCaller(t *testing.T) {
ctx := WithBridgeCallerPlugin(context.Background(), "spoofed")
ctx = WithBridgeCallerPlugin(ctx, "")
if got, ok := BridgeCallerPlugin(ctx); ok || got != "" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want empty, false", got, ok)
}
}

26
plugin/load_once.go Normal file
View File

@ -0,0 +1,26 @@
package plugin
import (
"fmt"
"regexp"
)
const MaxLoadOnceKeyBytes = 128
var loadOnceKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
// ValidateLoadOnceKeys checks the stable identity contract shared by plugin
// authors, the packer, and the host loader.
func ValidateLoadOnceKeys(keys []string) error {
seen := make(map[string]bool, len(keys))
for _, key := range keys {
if len(key) == 0 || len(key) > MaxLoadOnceKeyBytes || !loadOnceKeyPattern.MatchString(key) {
return fmt.Errorf("invalid load-once key %q: must be 1-%d bytes of lowercase letters, digits, dot, underscore, or hyphen, starting alphanumeric", key, MaxLoadOnceKeyBytes)
}
if seen[key] {
return fmt.Errorf("duplicate load-once key %q", key)
}
seen[key] = true
}
return nil
}

27
plugin/load_once_test.go Normal file
View File

@ -0,0 +1,27 @@
package plugin
import "testing"
func TestValidateLoadOnceKeys(t *testing.T) {
tests := []struct {
name string
keys []string
ok bool
}{
{name: "valid stable keys", keys: []string{"documentation-menu.v1", "seed_defaults-2"}, ok: true},
{name: "empty", keys: []string{""}},
{name: "uppercase", keys: []string{"DocumentationMenu.v1"}},
{name: "duplicate", keys: []string{"menu.v1", "menu.v1"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateLoadOnceKeys(test.keys)
if test.ok && err != nil {
t.Fatalf("ValidateLoadOnceKeys() error = %v", err)
}
if !test.ok && err == nil {
t.Fatal("ValidateLoadOnceKeys() error = nil")
}
})
}
}

View File

@ -91,6 +91,8 @@ type ModPublicRoute struct {
type ModCompat struct {
BlockCore string `toml:"block_core"`
AdminAPI string `toml:"admin_api"`
PluginSDK string `toml:"plugin_sdk"`
}
type ModRequirement struct {

View File

@ -1,9 +1,12 @@
package plugin
import (
"bytes"
"fmt"
"strings"
"testing"
tomlpkg "github.com/BurntSushi/toml"
)
func TestParseModFull_BasicFields(t *testing.T) {
@ -278,6 +281,8 @@ version = "0.2.0"
[compatibility]
block_core = ">=1.5 <2.0"
admin_api = ">=0.1.2 <0.2.0"
plugin_sdk = ">=0.3.7 <0.4.0"
[[requires]]
name = "@blockninja/smartblock"
@ -291,8 +296,17 @@ version = ">=1.2"
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Compatibility == nil || m.Compatibility.BlockCore != ">=1.5 <2.0" {
t.Errorf("Compat = %+v", m.Compatibility)
if m.Compatibility == nil {
t.Fatal("Compatibility is nil")
}
if m.Compatibility.BlockCore != ">=1.5 <2.0" {
t.Errorf("Compat.BlockCore = %q", m.Compatibility.BlockCore)
}
if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" {
t.Errorf("Compat.AdminAPI = %q", m.Compatibility.AdminAPI)
}
if m.Compatibility.PluginSDK != ">=0.3.7 <0.4.0" {
t.Errorf("Compat.PluginSDK = %q", m.Compatibility.PluginSDK)
}
if len(m.Requires) != 2 {
t.Fatalf("Requires len = %d, want 2", len(m.Requires))
@ -305,6 +319,33 @@ version = ">=1.2"
}
}
func TestModCompatPluginSDKRoundTrip(t *testing.T) {
t.Parallel()
want := &ModFile{
Plugin: ModPlugin{Name: "wiki", Version: "0.2.0"},
Compatibility: &ModCompat{
BlockCore: ">=0.3.4",
AdminAPI: ">=0.1.2",
PluginSDK: ">=0.3.7 <0.4.0",
},
}
var encoded bytes.Buffer
if err := tomlpkg.NewEncoder(&encoded).Encode(want); err != nil {
t.Fatalf("encode plugin.mod: %v", err)
}
if !strings.Contains(encoded.String(), `plugin_sdk = ">=0.3.7 <0.4.0"`) {
t.Fatalf("encoded plugin.mod omitted plugin_sdk:\n%s", encoded.String())
}
got, err := ParseModFull(encoded.Bytes())
if err != nil {
t.Fatalf("ParseModFull(round trip): %v", err)
}
if got.Compatibility == nil || got.Compatibility.PluginSDK != want.Compatibility.PluginSDK {
t.Fatalf("round-trip PluginSDK = %#v, want %q", got.Compatibility, want.Compatibility.PluginSDK)
}
}
func TestNormalizeTags_HappyPath(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
if err != nil {

View File

@ -41,8 +41,24 @@ type DataTableConfig struct {
// PageConfig defines a page to provision.
type PageConfig struct {
Slug string
ParentSlug string
// Key is the stable, plugin-scoped identity used to find the page across
// slug and hierarchy changes.
Key string
Slug string
// ParentKey identifies a parent provisioned by the same plugin.
ParentKey string
// ParentSlug is the legacy slug-based parent reference. New declarations
// should use ParentKey so moving or renaming the parent does not break the
// relationship.
ParentSlug string
// MountPath is applied when the page is first created. Reconciliation does
// not move an existing page when this value later changes.
MountPath string
Title string
TemplateKey string
Blocks []PageBlockConfig
@ -51,6 +67,10 @@ type PageConfig struct {
DetailSlugField string
ReconcileBlocks bool
ReconcileTemplate bool
// MergeMissingBlockContent shallowly adds absent top-level default keys to
// matching plugin-owned block assignments. Present values, including empty
// strings, zero, false, and null, are preserved.
MergeMissingBlockContent bool
}
// PageBlockConfig defines a block within a provisioned page.
@ -72,10 +92,15 @@ type EmbeddingConfigDef struct {
// MenuItemConfig defines a menu item to provision.
type MenuItemConfig struct {
Label string
URL string
PageSlug string
SortOrder int32
Label string
URL string
PageSlug string
// PagePlugin and PageKey identify a provisioned page independently of its
// current slug. PagePlugin permits a menu declared by one plugin to target
// a page owned by another.
PagePlugin string
PageKey string
SortOrder int32
}
// EmbedConfig defines an embeddable component template.

View File

@ -68,7 +68,7 @@ func checkRoutePath(r ModPublicRoute) string {
if strings.HasSuffix(p, "/") {
return "no trailing slash; set prefix = true to claim the subtree"
}
for _, seg := range strings.Split(p[1:], "/") {
for seg := range strings.SplitSeq(p[1:], "/") {
switch {
case seg == "":
return "empty path segment"

View File

@ -13,11 +13,20 @@ import (
// Plugins register their templates and blocks through the provided registries.
type RegisterFunc func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error
// LoadOnceHook is a persistent, keyed load callback. The host runs Run after
// the ordinary Load hook and records a successful execution by plugin name and
// Key. The same key is never run successfully again on that site; changing the
// key explicitly schedules a replacement callback once.
type LoadOnceHook struct {
Key string
Run func(deps CoreServices) error
}
// PluginRegistration defines a compiled-in plugin's entry points.
type PluginRegistration struct {
Name string
Register RegisterFunc
Register RegisterFunc
RegisterWithProvisioner func(tr templates.TemplateRegistry, br blocks.BlockRegistry, p Provisioner) error
Assets func() http.Handler
@ -39,8 +48,9 @@ type PluginRegistration struct {
DirectoryExtensions func() *DirectoryExtensions
MediaHooks MediaHooksProvider
Load func(deps CoreServices) error
Unload func(ctx context.Context) error
Load func(deps CoreServices) error
LoadOnce []LoadOnceHook
Unload func(ctx context.Context) error
Dependencies []Dependency
Migrations func() fs.FS

View File

@ -790,7 +790,9 @@ func TestCapabilityRoundTrip(t *testing.T) {
resp: &abiv1.ProvisionerEnsurePageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsurePage(plugin.PageConfig{
Slug: "/", Title: "Home", TemplateKey: "homepage", ReconcileBlocks: true,
Key: "home", Slug: "/", ParentKey: "site", ParentSlug: "/legacy-site",
MountPath: "/knowledge", Title: "Home", TemplateKey: "homepage",
ReconcileBlocks: true, MergeMissingBlockContent: true,
Blocks: []plugin.PageBlockConfig{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"x": float64(1)}, Slot: "main", SortOrder: 1},
},
@ -813,7 +815,9 @@ func TestCapabilityRoundTrip(t *testing.T) {
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})
err := cs.Provisioner.EnsureMenuItem("main", plugin.MenuItemConfig{
Label: "Docs", PageSlug: "/docs", PagePlugin: "wiki", PageKey: "index", SortOrder: 3,
})
if err != nil {
t.Fatal(err)
}
@ -1008,6 +1012,41 @@ func TestCapabilityRoundTrip(t *testing.T) {
}
}
func TestProvisionerPageContractGolden(t *testing.T) {
raw, err := os.ReadFile(goldenPath("provisioner_ensure_page_req"))
if err != nil {
t.Fatal(err)
}
req := &abiv1.ProvisionerEnsurePageRequest{}
if err := proto.Unmarshal(raw, req); err != nil {
t.Fatal(err)
}
page := req.GetPage()
if page == nil {
t.Fatal("page is nil")
}
if page.GetKey() != "home" || page.GetSlug() != "/" ||
page.GetParentKey() != "site" || page.GetParentSlug() != "/legacy-site" ||
page.GetMountPath() != "/knowledge" || !page.GetMergeMissingBlockContent() {
t.Errorf("page seed = %+v", page)
}
}
func TestProvisionerMenuPageReferenceContractGolden(t *testing.T) {
raw, err := os.ReadFile(goldenPath("provisioner_ensure_menu_item_req"))
if err != nil {
t.Fatal(err)
}
req := &abiv1.ProvisionerEnsureMenuItemRequest{}
if err := proto.Unmarshal(raw, req); err != nil {
t.Fatal(err)
}
if req.GetMenuName() != "main" || req.GetPageSlug() != "/docs" ||
req.GetPagePlugin() != "wiki" || req.GetPageKey() != "index" {
t.Errorf("menu request = %+v", req)
}
}
// fakeAbiErr is a transport error carrying an ABI code, like wasmguest.HostError.
type fakeAbiErr struct {
code abiv1.AbiErrorCode

View File

@ -53,15 +53,19 @@ func (s *provisionerStub) EnsureSetting(key string, defaultValue any) error {
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,
Key: config.Key,
Slug: config.Slug,
ParentKey: config.ParentKey,
ParentSlug: config.ParentSlug,
MountPath: config.MountPath,
Title: config.Title,
TemplateKey: config.TemplateKey,
DetailSourceType: config.DetailSourceType,
DetailSourceKey: config.DetailSourceKey,
DetailSlugField: config.DetailSlugField,
ReconcileBlocks: config.ReconcileBlocks,
ReconcileTemplate: config.ReconcileTemplate,
MergeMissingBlockContent: config.MergeMissingBlockContent,
}
for _, b := range config.Blocks {
contentJSON, err := json.Marshal(b.Content)
@ -92,11 +96,13 @@ func (s *provisionerStub) OverrideSiteSettings(overrides map[string]any) error {
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,
MenuName: menuName,
Label: config.Label,
Url: config.URL,
PageSlug: config.PageSlug,
PagePlugin: config.PagePlugin,
PageKey: config.PageKey,
SortOrder: config.SortOrder,
}
return s.invoke(context.Background(), "ensure_menu_item", req, &abiv1.ProvisionerEnsureMenuItemResponse{})
}

View File

@ -1,2 +1,2 @@
mainDocs"/docs(
mainDocs"/docs(2wiki:index

View File

@ -1,4 +1,5 @@
4
/Home"homepage*
htmlHero{"x":1}*main0H
\
/ /legacy-siteHome"homepage*
htmlHero{"x":1}*main0HZhomebsitej
/knowledgep

View File

@ -37,11 +37,13 @@ func contextFromRenderContext(ctx context.Context, rc *abiv1.RenderContext) cont
}
if p := rc.GetPage(); p != nil {
ctx = blocks.WithCurrentPage(ctx, &blocks.PageContext{
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
Title: p.GetTitle(),
PostType: p.GetPostType(),
Status: p.GetStatus(),
ID: parseUUID(p.GetId()),
Slug: p.GetSlug(),
DerivedSlug: p.GetDerivedSlug(),
MountPath: p.GetMountPath(),
Title: p.GetTitle(),
PostType: p.GetPostType(),
Status: p.GetStatus(),
})
}
if p := rc.GetPost(); p != nil {
@ -159,6 +161,8 @@ func blockContextFromProto(pb *abiv1.BlockContext) *blocks.BlockContext {
URL: pb.GetUrl(),
Path: pb.GetPath(),
Slug: pb.GetSlug(),
DerivedSlug: pb.GetDerivedSlug(),
MountPath: pb.GetMountPath(),
PageID: pb.GetPageId(),
PageTitle: pb.GetPageTitle(),
TemplateKey: pb.GetTemplateKey(),

View File

@ -0,0 +1,51 @@
package wasmguest
import (
"context"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"github.com/google/uuid"
)
func TestContextFromRenderContextCarriesResolvedPagePaths(t *testing.T) {
pageID := uuid.MustParse("5f0c5f3f-3f1e-4a3a-9b6e-2f4f6a6d7e8f")
ctx := contextFromRenderContext(context.Background(), &abiv1.RenderContext{
Page: &abiv1.PageContext{
Id: pageID.String(),
Slug: "/article",
DerivedSlug: "/knowledge/article",
MountPath: "/knowledge",
Title: "Article",
PostType: "page",
Status: "published",
},
BlockContext: &abiv1.BlockContext{
Slug: "/article",
DerivedSlug: "/knowledge/article",
MountPath: "/knowledge",
},
})
page := blocks.GetCurrentPage(ctx)
if page == nil {
t.Fatal("current page is nil")
}
if page.ID != pageID || page.Slug != "/article" ||
page.DerivedSlug != "/knowledge/article" || page.MountPath != "/knowledge" {
t.Errorf("page context = %+v", page)
}
block := blocks.GetBlockContext(ctx)
if block == nil {
t.Fatal("block context is nil")
}
if block.Slug != "/article" || block.DerivedSlug != "/knowledge/article" || block.MountPath != "/knowledge" {
t.Errorf("block context = %+v", block)
}
data := block.ToMap()
if data["derivedSlug"] != "/knowledge/article" || data["mountPath"] != "/knowledge" {
t.Errorf("block template map = %#v", data)
}
}

View File

@ -147,6 +147,9 @@ func (g *guest) buildManifest() *abiv1.PluginManifest {
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
for _, hook := range reg.LoadOnce {
m.LoadOnceKeys = append(m.LoadOnceKeys, hook.Key)
}
// Custom template tags/filters the plugin registered via blocks.RegisterTag
// / blocks.RegisterFilter during the Register pass (runRegister reset the

View File

@ -14,6 +14,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/ai"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/bnwasm"
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/caps"
@ -99,7 +100,15 @@ func newGuest(reg plugin.PluginRegistration) *guest {
// Pool whose calls fail cleanly, matching the capability stubs.
g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport))
g.rag, _ = g.services.RAGService.(*caps.RAGStub)
// Register callbacks may use HostServices for capability-backed surfaces
// that are not passed as Register parameters, such as bridge services.
// Publish this partially initialized guest only for the synchronous
// registration pass, then restore the previous runtime. Serve installs g
// permanently after newGuest returns.
previous := current
current = g
g.registerErr = runRegister(reg, g.templates, g.blocks)
current = previous
return g
}
@ -185,6 +194,8 @@ func (g *guest) invoke(hookID uint32, req []byte) (out []byte) {
payload, abiErr = g.runJob(ctx, env.GetPayload())
case abiv1.Hook_HOOK_LOAD:
payload, abiErr = g.load(env.GetPayload())
case abiv1.Hook_HOOK_LOAD_ONCE:
payload, abiErr = g.loadOnce(env.GetPayload())
case abiv1.Hook_HOOK_UNLOAD:
payload, abiErr = g.unload(ctx)
case abiv1.Hook_HOOK_RAG_FETCH:
@ -251,7 +262,12 @@ func (g *guest) renderBlock(ctx context.Context, payload []byte) (proto.Message,
return nil, internalError("marshal powered block data: " + err.Error())
}
return &abiv1.RenderBlockResponse{
Powered: &abiv1.PoweredBlock{Template: pr.Template, DataJson: dataJSON},
Powered: &abiv1.PoweredBlock{
Template: pr.Template,
DataJson: dataJSON,
StatusCode: pr.StatusCode,
Metadata: documentMetadataToProto(pr.Metadata),
},
}, nil
}
return &abiv1.RenderBlockResponse{Html: out}, nil
@ -362,6 +378,8 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
tdoc.HtmlAttrs = pd.HTMLAttrs
tdoc.HeadExtraHtml = head
tdoc.BodyEndExtraHtml = end
tdoc.StatusCode = pd.StatusCode
tdoc.Metadata = documentMetadataToProto(pd.Metadata)
} else {
body, err := renderPart(component)
if err != nil {
@ -372,6 +390,19 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
return &abiv1.RenderTemplateResponse{Document: tdoc}, nil
}
func documentMetadataToProto(metadata *document.Metadata) *abiv1.DocumentMetadata {
if metadata == nil {
return nil
}
return &abiv1.DocumentMetadata{
Title: metadata.Title,
Description: metadata.Description,
CanonicalUrl: metadata.CanonicalURL,
ImageUrl: metadata.ImageURL,
Robots: metadata.Robots,
}
}
func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
if g.reg.HTTPHandler == nil {
return nil, &abiv1.AbiError{
@ -470,6 +501,29 @@ func (g *guest) load(payload []byte) (proto.Message, *abiv1.AbiError) {
return &abiv1.LoadResponse{}, nil
}
func (g *guest) loadOnce(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.LoadOnceRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("LoadOnceRequest", err)
}
for _, hook := range g.reg.LoadOnce {
if hook.Key != req.GetKey() {
continue
}
if hook.Run == nil {
return nil, internalError("load-once hook " + hook.Key + " has no callback")
}
if err := hook.Run(g.services); err != nil {
return nil, internalError(err.Error())
}
return &abiv1.LoadOnceResponse{}, nil
}
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: "unknown load-once key " + req.GetKey(),
}
}
func (g *guest) unload(ctx context.Context) (proto.Message, *abiv1.AbiError) {
if g.reg.Unload != nil {
if err := g.reg.Unload(ctx); err != nil {
@ -667,6 +721,7 @@ func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message,
Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable",
}
}
ctx = plugin.WithBridgeCallerPlugin(ctx, req.GetCallerPlugin())
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
if err != nil {
return nil, internalError(err.Error())

View File

@ -10,6 +10,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
"google.golang.org/protobuf/proto"
@ -24,6 +25,12 @@ func (c textComponent) Render(_ context.Context, w io.Writer) error {
return err
}
type bridgeInvokableFunc func(context.Context, string, []byte) ([]byte, error)
func (f bridgeInvokableFunc) InvokeBridge(ctx context.Context, method string, payload []byte) ([]byte, error) {
return f(ctx, method, payload)
}
func fixtureRegistration() plugin.PluginRegistration {
return plugin.PluginRegistration{
Name: "fixture",
@ -123,6 +130,76 @@ func TestServeInstallsRuntime(t *testing.T) {
}
}
func TestServeExposesHostServicesDuringRegister(t *testing.T) {
previous := current
t.Cleanup(func() { current = previous })
Serve(plugin.PluginRegistration{
Name: "bridge-provider",
Version: "0.1.0",
Register: func(_ templates.TemplateRegistry, _ blocks.BlockRegistry) error {
bridge := HostServices().Bridge
if bridge == nil {
return fmt.Errorf("HostServices bridge is unavailable during Register")
}
bridge.RegisterService("bridge-provider", "content", bridgeInvokableFunc(
func(_ context.Context, _ string, _ []byte) ([]byte, error) {
return []byte("registered"), nil
},
))
return nil
},
})
resp := invokeHook(t, current, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
ServiceName: "content",
Method: "apply",
})
if resp.GetError() != nil {
t.Fatalf("bridge call returned error: %v", resp.GetError())
}
bridgeResp := &abiv1.BridgeCallResponse{}
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
}
if got := string(bridgeResp.GetPayload()); got != "registered" {
t.Fatalf("bridge payload = %q, want registered", got)
}
}
func TestBridgeCallProvidesAuthenticatedCallerContext(t *testing.T) {
g := newGuest(fixtureRegistration())
g.services.Bridge.RegisterService("fixture", "content", bridgeInvokableFunc(
func(ctx context.Context, method string, payload []byte) ([]byte, error) {
caller, ok := plugin.BridgeCallerPlugin(ctx)
if !ok || caller != "website" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", caller, ok)
}
if method != "ApplyBundle" || string(payload) != `{"revision":"1"}` {
t.Fatalf("bridge call = %q %q", method, payload)
}
return []byte(`{"created":1}`), nil
},
))
resp := invokeHook(t, g, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
ServiceName: "content",
Method: "ApplyBundle",
Payload: []byte(`{"revision":"1"}`),
CallerPlugin: "website",
})
if resp.GetError() != nil {
t.Fatalf("bridge call returned error: %v", resp.GetError())
}
bridgeResp := &abiv1.BridgeCallResponse{}
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
}
if got := string(bridgeResp.GetPayload()); got != `{"created":1}` {
t.Fatalf("bridge payload = %q, want report", got)
}
}
func TestDescribeManifest(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
@ -383,6 +460,14 @@ func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
HTMLAttrs: map[string]string{"data-theme": "x"},
HeadExtra: textComponent(`<meta name="e">`),
BodyEndExtra: textComponent("<script>1</script>"),
StatusCode: 201,
Metadata: &document.Metadata{
Title: "Page title",
Description: "Page description",
CanonicalURL: "https://example.com/page",
ImageURL: "https://example.com/page.png",
Robots: "index,follow",
},
}
})
},
@ -419,6 +504,17 @@ func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
if string(doc.GetBodyEndExtraHtml()) != "<script>1</script>" {
t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
}
if doc.GetStatusCode() != 201 {
t.Errorf("status_code = %d, want 201", doc.GetStatusCode())
}
metadata := doc.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Page title" ||
metadata.GetDescription() != "Page description" ||
metadata.GetCanonicalUrl() != "https://example.com/page" ||
metadata.GetImageUrl() != "https://example.com/page.png" ||
metadata.GetRobots() != "index,follow" {
t.Errorf("metadata = %+v", metadata)
}
}
func TestJobHook(t *testing.T) {
@ -451,3 +547,41 @@ func TestLoadUnloadHooks(t *testing.T) {
t.Fatalf("unload error: %v", resp.GetError())
}
}
func TestDescribeAndDispatchLoadOnceHook(t *testing.T) {
calls := 0
reg := fixtureRegistration()
reg.LoadOnce = []plugin.LoadOnceHook{{
Key: "documentation-menu.v1",
Run: func(plugin.CoreServices) error {
calls++
return nil
},
}}
g := newGuest(reg)
describe := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if describe.GetError() != nil {
t.Fatalf("describe error: %v", describe.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(describe.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal DescribeResponse: %v", err)
}
if got := dr.GetManifest().GetLoadOnceKeys(); len(got) != 1 || got[0] != "documentation-menu.v1" {
t.Fatalf("load_once_keys = %v", got)
}
resp := invokeHook(t, g, abiv1.Hook_HOOK_LOAD_ONCE, &abiv1.LoadOnceRequest{Key: "documentation-menu.v1"})
if resp.GetError() != nil {
t.Fatalf("load-once error: %v", resp.GetError())
}
if calls != 1 {
t.Fatalf("calls = %d, want 1", calls)
}
resp = invokeHook(t, g, abiv1.Hook_HOOK_LOAD_ONCE, &abiv1.LoadOnceRequest{Key: "unknown.v1"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("unknown key error = %v, want UNIMPLEMENTED", resp.GetError())
}
}

View File

@ -74,8 +74,8 @@ func methodDocumentation(method protoreflect.MethodDescriptor) (description, doc
}
paragraph = strings.TrimSpace(strings.ReplaceAll(paragraph, "\n", " "))
methodName := string(method.Name())
if strings.HasPrefix(paragraph, methodName+" ") {
paragraph = strings.TrimSpace(strings.TrimPrefix(paragraph, methodName+" "))
if after, ok := strings.CutPrefix(paragraph, methodName+" "); ok {
paragraph = strings.TrimSpace(after)
if paragraph != "" {
runes := []rune(paragraph)
runes[0] = unicode.ToUpper(runes[0])

View File

@ -65,30 +65,30 @@ func registerMCPFixture(t *testing.T) {
return
}
file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{
Name: proto.String("test/mcpfixture.proto"),
Package: proto.String("mcpfixture.v1"),
Syntax: proto.String("proto3"),
Name: new("test/mcpfixture.proto"),
Package: new("mcpfixture.v1"),
Syntax: new("proto3"),
Dependency: []string{"google/protobuf/timestamp.proto"},
MessageType: []*descriptorpb.DescriptorProto{{
Name: proto.String("ListArticlesRequest"),
Name: new("ListArticlesRequest"),
Field: []*descriptorpb.FieldDescriptorProto{
{Name: proto.String("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()},
{Name: proto.String("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()},
{Name: proto.String("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".google.protobuf.Timestamp")},
{Name: new("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()},
{Name: new("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()},
{Name: new("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: new(".google.protobuf.Timestamp")},
},
}, {
Name: proto.String("ListArticlesResponse"),
Name: new("ListArticlesResponse"),
}},
Service: []*descriptorpb.ServiceDescriptorProto{{
Name: proto.String("WikiService"),
Name: new("WikiService"),
Method: []*descriptorpb.MethodDescriptorProto{{
Name: proto.String("ListArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"),
Name: new("ListArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"),
}, {
Name: proto.String("StreamArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: proto.Bool(true),
Name: new("StreamArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: new(true),
}},
}},
SourceCodeInfo: &descriptorpb.SourceCodeInfo{Location: []*descriptorpb.SourceCodeInfo_Location{{
Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: proto.String("ListArticles lists Wiki articles.\n\nSupports pagination."),
Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: new("ListArticles lists Wiki articles.\n\nSupports pagination."),
}}},
}, protoregistry.GlobalFiles)
if err != nil {

View File

@ -8,6 +8,7 @@ import (
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
"google.golang.org/protobuf/proto"
@ -25,7 +26,20 @@ func tagFilterRegistration() plugin.PluginRegistration {
func(ctx context.Context, content map[string]any) string {
title, _ := content["title"].(string)
// Powered: return template + data; the host renders it.
return blocks.PoweredBlock("<h1>{{ title }}</h1>", map[string]any{"title": title})
return blocks.PoweredBlockWithOptions(
"<h1>{{ title }}</h1>",
map[string]any{"title": title},
blocks.PoweredOptions{
StatusCode: 404,
Metadata: &document.Metadata{
Title: "Missing",
Description: "Missing article",
CanonicalURL: "https://example.com/missing",
ImageURL: "https://example.com/missing.png",
Robots: "noindex,nofollow",
},
},
)
})
blocks.RegisterTag("greet", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
who, _ := args["who"].(string)
@ -111,6 +125,17 @@ func TestPoweredBlockRoundTrip(t *testing.T) {
if !strings.Contains(string(p.GetDataJson()), `"title":"Welcome"`) {
t.Errorf("data_json = %s", p.GetDataJson())
}
if p.GetStatusCode() != 404 {
t.Errorf("status_code = %d, want 404", p.GetStatusCode())
}
metadata := p.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Missing" ||
metadata.GetDescription() != "Missing article" ||
metadata.GetCanonicalUrl() != "https://example.com/missing" ||
metadata.GetImageUrl() != "https://example.com/missing.png" ||
metadata.GetRobots() != "noindex,nofollow" {
t.Errorf("metadata = %+v", metadata)
}
}
func TestRenderTagRoundTrip(t *testing.T) {

View File

@ -10,6 +10,7 @@ import (
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/document"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/templates"
)
@ -45,7 +46,20 @@ var Registration = plugin.PluginRegistration{
if powered, _ := content["powered"].(bool); powered {
// Powered path: hand the host a template + data instead of final
// HTML, so the host renders it (pongo2) after this call returns.
return blocks.PoweredBlock("<p>hello {{ name }}</p>", map[string]any{"name": name})
return blocks.PoweredBlockWithOptions(
"<p>hello {{ name }}</p>",
map[string]any{"name": name},
blocks.PoweredOptions{
StatusCode: 404,
Metadata: &document.Metadata{
Title: "Missing greeting",
Description: "The greeting was not found.",
CanonicalURL: "https://example.com/greetings/missing",
ImageURL: "https://example.com/greetings/missing.png",
Robots: "noindex,nofollow",
},
},
)
}
return fmt.Sprintf("<p>hello %s</p>", name)
})

View File

@ -241,6 +241,17 @@ func TestWasmFixtureTagFilterPoweredRoundTrip(t *testing.T) {
if !strings.Contains(string(p.GetDataJson()), `"name":"wazero"`) {
t.Errorf("powered data_json = %s", p.GetDataJson())
}
if p.GetStatusCode() != 404 {
t.Errorf("powered status_code = %d, want 404", p.GetStatusCode())
}
metadata := p.GetMetadata()
if metadata == nil || metadata.GetTitle() != "Missing greeting" ||
metadata.GetDescription() != "The greeting was not found." ||
metadata.GetCanonicalUrl() != "https://example.com/greetings/missing" ||
metadata.GetImageUrl() != "https://example.com/greetings/missing.png" ||
metadata.GetRobots() != "noindex,nofollow" {
t.Errorf("powered metadata = %+v", metadata)
}
// HOOK_RENDER_TAG dispatches to the registered tag fn (fresh invoke —
// no re-entrancy; RENDER_BLOCK already returned).

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"html"
"net/url"
"strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
@ -350,7 +351,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
}
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if link != "" {
if link = safeLinkURL(link); link != "" {
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
}
// The positioning wrapper only exists when a chip is rendered, so
@ -424,8 +425,8 @@ func renderBlock(ctx context.Context, block map[string]any) string {
name = url
}
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
if url != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
if href := safeLinkURL(url); href != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(href))
sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>")
} else {
@ -627,6 +628,7 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
case "link":
href, _ := itemMap["href"].(string)
href = safeLinkURL(href)
linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" {
sb.WriteString(renderInlineContent(linkContent, insideLink))
@ -656,6 +658,30 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
return sb.String()
}
// safeLinkURL accepts ordinary web, email, telephone, and relative links while
// rejecting active-content schemes such as javascript: and data:. Escaping a
// URL protects the HTML attribute boundary, but it does not make an unsafe URL
// scheme safe to navigate to.
func safeLinkURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" {
if err != nil {
return ""
}
return trimmed
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https", "mailto", "tel":
return trimmed
default:
return ""
}
}
// imageChipClass matches the page-builder image block's attribution chip
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
// blog and page credits identically.
@ -700,8 +726,9 @@ func writeAttributionLink(b *strings.Builder, href, label string) {
// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
// Closing parens, brackets and braces are kept inside the URL only when
// balanced with an opener inside the URL itself — so
// "(see https://example.com)" links only "https://example.com"
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
//
// "(see https://example.com)" links only "https://example.com"
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
func autolinkText(text string) string {
const scheme = "https://"
var sb strings.Builder

View File

@ -183,6 +183,27 @@ func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) {
}
}
func TestBlockNoteToHTML_RejectsActiveContentExplicitLink(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{
"type": "link",
"href": " javascript:alert(1) ",
"content": []any{map[string]any{"type": "text", "text": "read this"}},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if html != "<p class=\"my-4\">read this</p>\n" {
t.Fatalf("unsafe link should render as plain text: %q", html)
}
}
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) {
doc := map[string]any{
"blocks": []any{

View File

@ -236,6 +236,28 @@ func TestFileBlock(t *testing.T) {
}
}
func TestFileBlockRejectsActiveContentURL(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "javascript:alert(document.domain)",
"name": "Unsafe link",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if strings.Contains(html, "href=") || strings.Contains(html, "javascript:") {
t.Fatalf("unsafe file URL rendered as a link: %s", html)
}
if !strings.Contains(html, "Unsafe link") {
t.Fatalf("file label should remain visible as plain text: %s", html)
}
}
func TestFileBlockNoName(t *testing.T) {
doc := map[string]any{
"blocks": []any{
@ -708,6 +730,18 @@ func TestImageBlockAltAndLink(t *testing.T) {
}
}
func TestImageBlockRejectsActiveContentLink(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "alt": "Sunset", "link": "JaVaScRiPt:alert(1)",
}))
if strings.Contains(html, "<a ") || strings.Contains(strings.ToLower(html), "javascript:") {
t.Fatalf("unsafe image link rendered: %s", html)
}
if !strings.Contains(html, `<img src="/media/x.webp"`) {
t.Fatalf("image should still render without its unsafe link: %s", html)
}
}
func TestImageBlockWidthAndAlign(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "width": "medium", "align": "right",

View File

@ -3,6 +3,8 @@ package templates
import (
"context"
"io"
"git.dev.alexdunmow.com/block/pluginsdk/document"
)
// PageDocument is a full-page template's contribution to the document. The
@ -17,6 +19,8 @@ type PageDocument struct {
HTMLAttrs map[string]string // extra <html> attributes (e.g. data-theme)
HeadExtra HTMLComponent // appended inside <head> after bn.Head
BodyEndExtra HTMLComponent // appended before </body> after bn.BodyEnd
StatusCode int32 // requested HTTP status; host accepts 200599
Metadata *document.Metadata
}
func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {