diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index 01e2726..1fd166b 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -139,6 +139,34 @@ message PluginManifest { // than the 10 MB default. Rides the same admin grant as allowed_hosts. // Zero means the default. uint32 max_response_mb = 35; + + // public_routes declares the site-root paths the plugin serves through its + // HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host + // mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the + // full unstripped request path. Like data_dir and allowed_hosts, the source + // of truth is plugin.mod; the packer validates and stamps it. Conflict + // rules are host-side: core routes always win, between plugins the + // first-installed plugin wins, and conflicts are surfaced in the admin UI, + // never silently dropped. Empty means the plugin serves HTTP only under + // /api/plugins/. + repeated PublicRoute public_routes = 36; + + // sitemap requests sitemap contribution: when true the host fetches + // entries from the plugin's well-known sitemap endpoint (served by its + // HTTP handler) and merges them into the site sitemap. Requires + // has_http_handler. + bool sitemap = 37; +} + +// PublicRoute is one site-root path claim (see PluginManifest.public_routes). +message PublicRoute { + // Absolute site-root path, e.g. "/area" or "/api/directory". Validated by + // the packer: must start with "/", no reserved prefixes ("/admin", + // "/api/plugins", "/ws"), no traversal or empty segments. + string path = 1; + // When true the claim covers every path under path as well (a path-prefix + // mount); when false only the exact path is claimed. + bool prefix = 2; } // Dependency mirrors plugin.Dependency. diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index 1f5e79a..a870f31 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -135,6 +135,21 @@ type PluginManifest struct { // than the 10 MB default. Rides the same admin grant as allowed_hosts. // Zero means the default. MaxResponseMb uint32 `protobuf:"varint,35,opt,name=max_response_mb,json=maxResponseMb,proto3" json:"max_response_mb,omitempty"` + // public_routes declares the site-root paths the plugin serves through its + // HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host + // mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the + // full unstripped request path. Like data_dir and allowed_hosts, the source + // of truth is plugin.mod; the packer validates and stamps it. Conflict + // rules are host-side: core routes always win, between plugins the + // first-installed plugin wins, and conflicts are surfaced in the admin UI, + // never silently dropped. Empty means the plugin serves HTTP only under + // /api/plugins/. + PublicRoutes []*PublicRoute `protobuf:"bytes,36,rep,name=public_routes,json=publicRoutes,proto3" json:"public_routes,omitempty"` + // sitemap requests sitemap contribution: when true the host fetches + // entries from the plugin's well-known sitemap endpoint (served by its + // HTTP handler) and merges them into the site sitemap. Requires + // has_http_handler. + Sitemap bool `protobuf:"varint,37,opt,name=sitemap,proto3" json:"sitemap,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -414,6 +429,78 @@ func (x *PluginManifest) GetMaxResponseMb() uint32 { return 0 } +func (x *PluginManifest) GetPublicRoutes() []*PublicRoute { + if x != nil { + return x.PublicRoutes + } + return nil +} + +func (x *PluginManifest) GetSitemap() bool { + if x != nil { + return x.Sitemap + } + return false +} + +// PublicRoute is one site-root path claim (see PluginManifest.public_routes). +type PublicRoute struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Absolute site-root path, e.g. "/area" or "/api/directory". Validated by + // the packer: must start with "/", no reserved prefixes ("/admin", + // "/api/plugins", "/ws"), no traversal or empty segments. + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // When true the claim covers every path under path as well (a path-prefix + // mount); when false only the exact path is claimed. + Prefix bool `protobuf:"varint,2,opt,name=prefix,proto3" json:"prefix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PublicRoute) Reset() { + *x = PublicRoute{} + mi := &file_v1_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PublicRoute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PublicRoute) ProtoMessage() {} + +func (x *PublicRoute) ProtoReflect() protoreflect.Message { + mi := &file_v1_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PublicRoute.ProtoReflect.Descriptor instead. +func (*PublicRoute) Descriptor() ([]byte, []int) { + return file_v1_manifest_proto_rawDescGZIP(), []int{1} +} + +func (x *PublicRoute) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *PublicRoute) GetPrefix() bool { + if x != nil { + return x.Prefix + } + return false +} + // Dependency mirrors plugin.Dependency. type Dependency struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -426,7 +513,7 @@ type Dependency struct { func (x *Dependency) Reset() { *x = Dependency{} - mi := &file_v1_manifest_proto_msgTypes[1] + mi := &file_v1_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -438,7 +525,7 @@ func (x *Dependency) String() string { func (*Dependency) ProtoMessage() {} func (x *Dependency) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[1] + mi := &file_v1_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -451,7 +538,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message { // Deprecated: Use Dependency.ProtoReflect.Descriptor instead. func (*Dependency) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{1} + return file_v1_manifest_proto_rawDescGZIP(), []int{2} } func (x *Dependency) GetPlugin() string { @@ -494,7 +581,7 @@ type BlockMeta struct { func (x *BlockMeta) Reset() { *x = BlockMeta{} - mi := &file_v1_manifest_proto_msgTypes[2] + mi := &file_v1_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -506,7 +593,7 @@ func (x *BlockMeta) String() string { func (*BlockMeta) ProtoMessage() {} func (x *BlockMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[2] + mi := &file_v1_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -519,7 +606,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead. func (*BlockMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{2} + return file_v1_manifest_proto_rawDescGZIP(), []int{3} } func (x *BlockMeta) GetKey() string { @@ -585,7 +672,7 @@ type BlockTemplateOverride struct { func (x *BlockTemplateOverride) Reset() { *x = BlockTemplateOverride{} - mi := &file_v1_manifest_proto_msgTypes[3] + mi := &file_v1_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -597,7 +684,7 @@ func (x *BlockTemplateOverride) String() string { func (*BlockTemplateOverride) ProtoMessage() {} func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[3] + mi := &file_v1_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -610,7 +697,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message { // Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead. func (*BlockTemplateOverride) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{3} + return file_v1_manifest_proto_rawDescGZIP(), []int{4} } func (x *BlockTemplateOverride) GetTemplateKey() string { @@ -646,7 +733,7 @@ type SystemTemplateMeta struct { func (x *SystemTemplateMeta) Reset() { *x = SystemTemplateMeta{} - mi := &file_v1_manifest_proto_msgTypes[4] + mi := &file_v1_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -658,7 +745,7 @@ func (x *SystemTemplateMeta) String() string { func (*SystemTemplateMeta) ProtoMessage() {} func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[4] + mi := &file_v1_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -671,7 +758,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead. func (*SystemTemplateMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{4} + return file_v1_manifest_proto_rawDescGZIP(), []int{5} } func (x *SystemTemplateMeta) GetKey() string { @@ -710,7 +797,7 @@ type PageTemplateMeta struct { func (x *PageTemplateMeta) Reset() { *x = PageTemplateMeta{} - mi := &file_v1_manifest_proto_msgTypes[5] + mi := &file_v1_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -722,7 +809,7 @@ func (x *PageTemplateMeta) String() string { func (*PageTemplateMeta) ProtoMessage() {} func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[5] + mi := &file_v1_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -735,7 +822,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead. func (*PageTemplateMeta) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{5} + return file_v1_manifest_proto_rawDescGZIP(), []int{6} } func (x *PageTemplateMeta) GetSystemKey() string { @@ -786,7 +873,7 @@ type AdminPage struct { func (x *AdminPage) Reset() { *x = AdminPage{} - mi := &file_v1_manifest_proto_msgTypes[6] + mi := &file_v1_manifest_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -798,7 +885,7 @@ func (x *AdminPage) String() string { func (*AdminPage) ProtoMessage() {} func (x *AdminPage) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[6] + mi := &file_v1_manifest_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -811,7 +898,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminPage.ProtoReflect.Descriptor instead. func (*AdminPage) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{6} + return file_v1_manifest_proto_rawDescGZIP(), []int{7} } func (x *AdminPage) GetKey() string { @@ -856,7 +943,7 @@ type AiAction struct { func (x *AiAction) Reset() { *x = AiAction{} - mi := &file_v1_manifest_proto_msgTypes[7] + mi := &file_v1_manifest_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -868,7 +955,7 @@ func (x *AiAction) String() string { func (*AiAction) ProtoMessage() {} func (x *AiAction) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[7] + mi := &file_v1_manifest_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -881,7 +968,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message { // Deprecated: Use AiAction.ProtoReflect.Descriptor instead. func (*AiAction) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{7} + return file_v1_manifest_proto_rawDescGZIP(), []int{8} } func (x *AiAction) GetKey() string { @@ -931,7 +1018,7 @@ type CssManifest struct { func (x *CssManifest) Reset() { *x = CssManifest{} - mi := &file_v1_manifest_proto_msgTypes[8] + mi := &file_v1_manifest_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -943,7 +1030,7 @@ func (x *CssManifest) String() string { func (*CssManifest) ProtoMessage() {} func (x *CssManifest) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[8] + mi := &file_v1_manifest_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -956,7 +1043,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message { // Deprecated: Use CssManifest.ProtoReflect.Descriptor instead. func (*CssManifest) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{8} + return file_v1_manifest_proto_rawDescGZIP(), []int{9} } func (x *CssManifest) GetNpmPackages() map[string]string { @@ -997,7 +1084,7 @@ type DirectoryExtensions struct { func (x *DirectoryExtensions) Reset() { *x = DirectoryExtensions{} - mi := &file_v1_manifest_proto_msgTypes[9] + mi := &file_v1_manifest_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1009,7 +1096,7 @@ func (x *DirectoryExtensions) String() string { func (*DirectoryExtensions) ProtoMessage() {} func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[9] + mi := &file_v1_manifest_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1022,7 +1109,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead. func (*DirectoryExtensions) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{9} + return file_v1_manifest_proto_rawDescGZIP(), []int{10} } func (x *DirectoryExtensions) GetBooleanFilterFields() []string { @@ -1071,7 +1158,7 @@ type BadgeLabel struct { func (x *BadgeLabel) Reset() { *x = BadgeLabel{} - mi := &file_v1_manifest_proto_msgTypes[10] + mi := &file_v1_manifest_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1083,7 +1170,7 @@ func (x *BadgeLabel) String() string { func (*BadgeLabel) ProtoMessage() {} func (x *BadgeLabel) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[10] + mi := &file_v1_manifest_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1096,7 +1183,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message { // Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead. func (*BadgeLabel) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{10} + return file_v1_manifest_proto_rawDescGZIP(), []int{11} } func (x *BadgeLabel) GetPositive() string { @@ -1126,7 +1213,7 @@ type MasterPageDefinition struct { func (x *MasterPageDefinition) Reset() { *x = MasterPageDefinition{} - mi := &file_v1_manifest_proto_msgTypes[11] + mi := &file_v1_manifest_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1138,7 +1225,7 @@ func (x *MasterPageDefinition) String() string { func (*MasterPageDefinition) ProtoMessage() {} func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[11] + mi := &file_v1_manifest_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1151,7 +1238,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead. func (*MasterPageDefinition) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{11} + return file_v1_manifest_proto_rawDescGZIP(), []int{12} } func (x *MasterPageDefinition) GetKey() string { @@ -1198,7 +1285,7 @@ type MasterPageBlock struct { func (x *MasterPageBlock) Reset() { *x = MasterPageBlock{} - mi := &file_v1_manifest_proto_msgTypes[12] + mi := &file_v1_manifest_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1210,7 +1297,7 @@ func (x *MasterPageBlock) String() string { func (*MasterPageBlock) ProtoMessage() {} func (x *MasterPageBlock) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[12] + mi := &file_v1_manifest_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1223,7 +1310,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message { // Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead. func (*MasterPageBlock) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{12} + return file_v1_manifest_proto_rawDescGZIP(), []int{13} } func (x *MasterPageBlock) GetBlockKey() string { @@ -1280,7 +1367,7 @@ type CoreServiceBinding struct { func (x *CoreServiceBinding) Reset() { *x = CoreServiceBinding{} - mi := &file_v1_manifest_proto_msgTypes[13] + mi := &file_v1_manifest_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1292,7 +1379,7 @@ func (x *CoreServiceBinding) String() string { func (*CoreServiceBinding) ProtoMessage() {} func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message { - mi := &file_v1_manifest_proto_msgTypes[13] + mi := &file_v1_manifest_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1305,7 +1392,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead. func (*CoreServiceBinding) Descriptor() ([]byte, []int) { - return file_v1_manifest_proto_rawDescGZIP(), []int{13} + return file_v1_manifest_proto_rawDescGZIP(), []int{14} } func (x *CoreServiceBinding) GetServiceName() string { @@ -1326,7 +1413,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor const file_v1_manifest_proto_rawDesc = "" + "\n" + - "\x11v1/manifest.proto\x12\x06abi.v1\"\xdb\r\n" + + "\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" + "\x0ePluginManifest\x12\x1f\n" + "\vabi_version\x18\x01 \x01(\rR\n" + "abiVersion\x12\x12\n" + @@ -1366,10 +1453,15 @@ const file_v1_manifest_proto_rawDesc = "" + "\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" + "\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" + "\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" + - "\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x1aB\n" + + "\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\x1aB\n" + "\x14RbacMethodRolesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" + + "\vPublicRoute\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" + + "\x06prefix\x18\x02 \x01(\bR\x06prefix\"a\n" + "\n" + "Dependency\x12\x16\n" + "\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" + @@ -1463,50 +1555,52 @@ func file_v1_manifest_proto_rawDescGZIP() []byte { return file_v1_manifest_proto_rawDescData } -var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_v1_manifest_proto_goTypes = []any{ (*PluginManifest)(nil), // 0: abi.v1.PluginManifest - (*Dependency)(nil), // 1: abi.v1.Dependency - (*BlockMeta)(nil), // 2: abi.v1.BlockMeta - (*BlockTemplateOverride)(nil), // 3: abi.v1.BlockTemplateOverride - (*SystemTemplateMeta)(nil), // 4: abi.v1.SystemTemplateMeta - (*PageTemplateMeta)(nil), // 5: abi.v1.PageTemplateMeta - (*AdminPage)(nil), // 6: abi.v1.AdminPage - (*AiAction)(nil), // 7: abi.v1.AiAction - (*CssManifest)(nil), // 8: abi.v1.CssManifest - (*DirectoryExtensions)(nil), // 9: abi.v1.DirectoryExtensions - (*BadgeLabel)(nil), // 10: abi.v1.BadgeLabel - (*MasterPageDefinition)(nil), // 11: abi.v1.MasterPageDefinition - (*MasterPageBlock)(nil), // 12: abi.v1.MasterPageBlock - (*CoreServiceBinding)(nil), // 13: abi.v1.CoreServiceBinding - nil, // 14: abi.v1.PluginManifest.RbacMethodRolesEntry - nil, // 15: abi.v1.CssManifest.NpmPackagesEntry - nil, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry - nil, // 17: abi.v1.CoreServiceBinding.MethodRolesEntry + (*PublicRoute)(nil), // 1: abi.v1.PublicRoute + (*Dependency)(nil), // 2: abi.v1.Dependency + (*BlockMeta)(nil), // 3: abi.v1.BlockMeta + (*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride + (*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta + (*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta + (*AdminPage)(nil), // 7: abi.v1.AdminPage + (*AiAction)(nil), // 8: abi.v1.AiAction + (*CssManifest)(nil), // 9: abi.v1.CssManifest + (*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions + (*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel + (*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition + (*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock + (*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding + nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry + nil, // 16: abi.v1.CssManifest.NpmPackagesEntry + nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry + nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry } var file_v1_manifest_proto_depIdxs = []int32{ - 1, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency - 2, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta - 3, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride - 4, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta - 5, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta - 6, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage - 11, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition - 7, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction - 14, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry - 8, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest - 9, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions - 13, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding - 15, // 12: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry - 16, // 13: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry - 12, // 14: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock - 17, // 15: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry - 10, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel - 17, // [17:17] is the sub-list for method output_type - 17, // [17:17] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency + 3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta + 4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride + 5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta + 6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta + 7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage + 12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition + 8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction + 15, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry + 9, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest + 10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions + 14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding + 1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute + 16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry + 17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry + 13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock + 18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry + 11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel + 18, // [18:18] is the sub-list for method output_type + 18, // [18:18] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_v1_manifest_proto_init() } @@ -1514,14 +1608,14 @@ func file_v1_manifest_proto_init() { if File_v1_manifest_proto != nil { return } - file_v1_manifest_proto_msgTypes[12].OneofWrappers = []any{} + file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)), NumEnums: 0, - NumMessages: 18, + NumMessages: 19, NumExtensions: 0, NumServices: 0, }, diff --git a/plugin/mod.go b/plugin/mod.go index 6341f4a..07e9fa5 100644 --- a/plugin/mod.go +++ b/plugin/mod.go @@ -65,6 +65,28 @@ type ModPlugin struct { // than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero // means the default. MaxResponseMB uint32 `toml:"max_response_mb,omitempty"` + // PublicRoutes declares site-root paths the plugin serves through its + // HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the + // host mounts at the public site root. First-class for the same reason as + // DataDir and AllowedHosts: the writeMod round-trip drops any key without + // a struct home, and a dropped route claim silently unmounts live SEO + // URLs on the next publish. `ninja plugin build` validates the claims + // (ValidatePublicRoutes) and stamps them into the manifest + // (PluginManifest.public_routes). Empty means the plugin serves HTTP only + // under /api/plugins/. + PublicRoutes []ModPublicRoute `toml:"public_routes,omitempty"` + // Sitemap requests sitemap contribution: when true the host fetches + // entries from the plugin's well-known sitemap endpoint and merges them + // into the site sitemap. Stamped into the manifest alongside + // PublicRoutes; requires an HTTP handler. + Sitemap bool `toml:"sitemap,omitempty"` +} + +// ModPublicRoute is one site-root path claim in plugin.mod, e.g. +// { path = "/area", prefix = true }. +type ModPublicRoute struct { + Path string `toml:"path"` + Prefix bool `toml:"prefix,omitempty"` } type ModCompat struct { diff --git a/plugin/public_routes.go b/plugin/public_routes.go new file mode 100644 index 0000000..7218241 --- /dev/null +++ b/plugin/public_routes.go @@ -0,0 +1,90 @@ +package plugin + +import ( + "fmt" + "regexp" + "strings" +) + +// MaxPublicRoutes caps the number of site-root claims a single plugin may +// declare. Generous: real plugins claim a handful of SEO prefixes. +const MaxPublicRoutes = 64 + +// ReservedRoutePrefixes are site-root prefixes plugins may never claim: the +// admin SPA, the namespaced plugin HTTP mount, and the realtime websocket. +// A claim equal to or under one of these is rejected, as is a prefix claim +// that would cover one (e.g. prefix "/api" covers "/api/plugins"). +var ReservedRoutePrefixes = []string{"/admin", "/api/plugins", "/ws"} + +// routeSegmentRe matches one path segment: URL-safe unreserved characters. +var routeSegmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + +// ValidatePublicRoutes checks a plugin.mod public_routes declaration. It +// returns an error listing every offending entry so authors fix them in one +// pass. +// +// Rules: +// - at most MaxPublicRoutes entries +// - each path is absolute ("/..."), not the site root "/" itself +// - segments are non-empty (no "//", no trailing "/"), never "." or "..", +// and use URL-safe unreserved characters only +// - no claim equal to or under a reserved prefix (ReservedRoutePrefixes), +// and no prefix claim that covers a reserved prefix +// - no duplicate paths +func ValidatePublicRoutes(routes []ModPublicRoute) error { + if len(routes) > MaxPublicRoutes { + return fmt.Errorf("too many public_routes: got %d, max %d", len(routes), MaxPublicRoutes) + } + seen := make(map[string]struct{}, len(routes)) + var bad []string + for _, r := range routes { + if reason := checkRoutePath(r); reason != "" { + bad = append(bad, fmt.Sprintf("%q (%s)", r.Path, reason)) + continue + } + if _, dup := seen[r.Path]; dup { + bad = append(bad, fmt.Sprintf("%q (duplicate path)", r.Path)) + continue + } + seen[r.Path] = struct{}{} + } + if len(bad) > 0 { + return fmt.Errorf("invalid public_routes: %s", strings.Join(bad, "; ")) + } + return nil +} + +func checkRoutePath(r ModPublicRoute) string { + p := r.Path + if p == "" { + return "empty path" + } + if !strings.HasPrefix(p, "/") { + return "path must be absolute, starting with /" + } + if p == "/" { + return "cannot claim the site root" + } + if strings.HasSuffix(p, "/") { + return "no trailing slash; set prefix = true to claim the subtree" + } + for _, seg := range strings.Split(p[1:], "/") { + switch { + case seg == "": + return "empty path segment" + case seg == "." || seg == "..": + return "path traversal segment" + case !routeSegmentRe.MatchString(seg): + return fmt.Sprintf("segment %q has characters outside [A-Za-z0-9._~-]", seg) + } + } + for _, reserved := range ReservedRoutePrefixes { + if p == reserved || strings.HasPrefix(p, reserved+"/") { + return fmt.Sprintf("reserved prefix %s", reserved) + } + if r.Prefix && strings.HasPrefix(reserved, p+"/") { + return fmt.Sprintf("prefix claim covers reserved prefix %s", reserved) + } + } + return "" +} diff --git a/plugin/public_routes_test.go b/plugin/public_routes_test.go new file mode 100644 index 0000000..ca48828 --- /dev/null +++ b/plugin/public_routes_test.go @@ -0,0 +1,214 @@ +package plugin + +import ( + "fmt" + "strings" + "testing" +) + +func TestParseModFull_PublicRoutesAndSitemap(t *testing.T) { + src := []byte(` +[plugin] +name = "perthplaygrounds" +version = "1.0.0" +public_routes = [{ path = "/area", prefix = true }, { path = "/badges" }, { path = "/api/directory", prefix = true }] +sitemap = true +`) + m, err := ParseModFull(src) + if err != nil { + t.Fatalf("ParseModFull err: %v", err) + } + routes := m.Plugin.PublicRoutes + if len(routes) != 3 { + t.Fatalf("PublicRoutes len = %d, want 3 (%v)", len(routes), routes) + } + want := []ModPublicRoute{ + {Path: "/area", Prefix: true}, + {Path: "/badges", Prefix: false}, + {Path: "/api/directory", Prefix: true}, + } + for i, w := range want { + if routes[i] != w { + t.Errorf("PublicRoutes[%d] = %+v, want %+v", i, routes[i], w) + } + } + if !m.Plugin.Sitemap { + t.Errorf("Sitemap = false, want true") + } +} + +func TestParseModFull_PublicRoutesArrayOfTables(t *testing.T) { + src := []byte(` +[plugin] +name = "perthplaygrounds" +version = "1.0.0" + +[[plugin.public_routes]] +path = "/area" +prefix = true + +[[plugin.public_routes]] +path = "/badges" +`) + m, err := ParseModFull(src) + if err != nil { + t.Fatalf("ParseModFull err: %v", err) + } + routes := m.Plugin.PublicRoutes + if len(routes) != 2 { + t.Fatalf("PublicRoutes len = %d, want 2 (%v)", len(routes), routes) + } + if routes[0] != (ModPublicRoute{Path: "/area", Prefix: true}) { + t.Errorf("PublicRoutes[0] = %+v", routes[0]) + } + if routes[1] != (ModPublicRoute{Path: "/badges"}) { + t.Errorf("PublicRoutes[1] = %+v", routes[1]) + } +} + +func TestParseModFull_PublicRoutesOmitted(t *testing.T) { + src := []byte(` +[plugin] +name = "plain" +version = "0.1.0" +`) + m, err := ParseModFull(src) + if err != nil { + t.Fatalf("ParseModFull err: %v", err) + } + if m.Plugin.PublicRoutes != nil { + t.Errorf("PublicRoutes = %v, want nil when omitted", m.Plugin.PublicRoutes) + } + if m.Plugin.Sitemap { + t.Errorf("Sitemap = true, want false (absent key)") + } +} + +func TestValidatePublicRoutes_HappyPath(t *testing.T) { + err := ValidatePublicRoutes([]ModPublicRoute{ + {Path: "/area", Prefix: true}, + {Path: "/badges", Prefix: true}, + {Path: "/age", Prefix: true}, + {Path: "/api/directory", Prefix: true}, + {Path: "/api/semantic-search"}, + {Path: "/some_page.html"}, + }) + if err != nil { + t.Fatalf("ValidatePublicRoutes err: %v", err) + } +} + +func TestValidatePublicRoutes_EmptyAndNil(t *testing.T) { + if err := ValidatePublicRoutes(nil); err != nil { + t.Errorf("nil err: %v", err) + } + if err := ValidatePublicRoutes([]ModPublicRoute{}); err != nil { + t.Errorf("empty err: %v", err) + } +} + +func TestValidatePublicRoutes_RejectsReservedPrefixes(t *testing.T) { + cases := []ModPublicRoute{ + {Path: "/admin"}, + {Path: "/admin", Prefix: true}, + {Path: "/admin/settings"}, + {Path: "/api/plugins"}, + {Path: "/api/plugins/foo", Prefix: true}, + {Path: "/ws"}, + } + for _, c := range cases { + err := ValidatePublicRoutes([]ModPublicRoute{c}) + if err == nil { + t.Errorf("ValidatePublicRoutes(%+v) = nil, want reserved-prefix error", c) + continue + } + if !strings.Contains(err.Error(), "reserved prefix") { + t.Errorf("error for %+v does not mention reserved prefix: %v", c, err) + } + } +} + +func TestValidatePublicRoutes_RejectsPrefixCoveringReserved(t *testing.T) { + err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api", Prefix: true}}) + if err == nil { + t.Fatal("prefix claim /api should be rejected: it covers /api/plugins") + } + if !strings.Contains(err.Error(), "covers reserved prefix /api/plugins") { + t.Errorf("error does not explain the covered prefix: %v", err) + } + if err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api"}}); err != nil { + t.Errorf("exact claim /api should be allowed: %v", err) + } +} + +func TestValidatePublicRoutes_RejectsMalformedPaths(t *testing.T) { + cases := []struct { + route ModPublicRoute + frag string + }{ + {ModPublicRoute{Path: ""}, "empty path"}, + {ModPublicRoute{Path: "area"}, "absolute"}, + {ModPublicRoute{Path: "/"}, "site root"}, + {ModPublicRoute{Path: "/area/"}, "trailing slash"}, + {ModPublicRoute{Path: "/area//list"}, "empty path segment"}, + {ModPublicRoute{Path: "/area/../admin"}, "traversal"}, + {ModPublicRoute{Path: "/area?x=1"}, "characters outside"}, + {ModPublicRoute{Path: "/area list"}, "characters outside"}, + } + for _, c := range cases { + err := ValidatePublicRoutes([]ModPublicRoute{c.route}) + if err == nil { + t.Errorf("ValidatePublicRoutes(%q) = nil, want error", c.route.Path) + continue + } + if !strings.Contains(err.Error(), c.frag) { + t.Errorf("error for %q = %v, want mention of %q", c.route.Path, err, c.frag) + } + } +} + +func TestValidatePublicRoutes_RejectsDuplicates(t *testing.T) { + err := ValidatePublicRoutes([]ModPublicRoute{ + {Path: "/area"}, + {Path: "/area", Prefix: true}, + }) + if err == nil { + t.Fatal("duplicate path should be rejected") + } + if !strings.Contains(err.Error(), "duplicate path") { + t.Errorf("error does not mention duplicate: %v", err) + } +} + +func TestValidatePublicRoutes_ReportsAllOffendersInOnePass(t *testing.T) { + err := ValidatePublicRoutes([]ModPublicRoute{ + {Path: "bad"}, + {Path: "/admin/x"}, + {Path: "/fine"}, + }) + if err == nil { + t.Fatal("expected error") + } + for _, frag := range []string{`"bad"`, `"/admin/x"`} { + if !strings.Contains(err.Error(), frag) { + t.Errorf("error %q does not mention %s", err.Error(), frag) + } + } + if strings.Contains(err.Error(), `"/fine"`) { + t.Errorf("error should not name the valid route: %v", err) + } +} + +func TestValidatePublicRoutes_CapEnforced(t *testing.T) { + routes := make([]ModPublicRoute, MaxPublicRoutes+1) + for i := range routes { + routes[i] = ModPublicRoute{Path: fmt.Sprintf("/r%d", i)} + } + err := ValidatePublicRoutes(routes) + if err == nil { + t.Fatal("expected too-many error") + } + if !strings.Contains(err.Error(), "too many public_routes") { + t.Errorf("error %q does not mention the cap", err.Error()) + } +}