feat(bnp): marshal public_routes + sitemap into built manifests (ADR 0026)

pluginsdk v0.2.7. The packer validates public_routes (reserved
prefixes, traversal, duplicates), requires an HTTP handler for routes
or sitemap, and stamps both into manifest.pb; codeless builds reject
them; writeMod round-trips the new plugin.mod keys so a version bump
cannot strip live route claims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-12 02:45:58 +08:00
parent 4eb4bcfc50
commit 71d005f1fb
8 changed files with 146 additions and 4 deletions

View File

@ -1130,6 +1130,20 @@ func writeMod(path string, m *core.ModFile) error {
if m.Plugin.MaxResponseMB > 0 {
fmt.Fprintf(&b, "max_response_mb = %d\n", m.Plugin.MaxResponseMB)
}
if len(m.Plugin.PublicRoutes) > 0 {
entries := make([]string, len(m.Plugin.PublicRoutes))
for i, r := range m.Plugin.PublicRoutes {
if r.Prefix {
entries[i] = fmt.Sprintf("{ path = %q, prefix = true }", r.Path)
} else {
entries[i] = fmt.Sprintf("{ path = %q }", r.Path)
}
}
fmt.Fprintf(&b, "public_routes = [%s]\n", strings.Join(entries, ", "))
}
if m.Plugin.Sitemap {
b.WriteString("sitemap = true\n")
}
if m.Compatibility != nil {
b.WriteString("\n[compatibility]\n")
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)

View File

@ -380,6 +380,11 @@ func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
AllowedHosts: []string{"api.cal.com", "*.example.com"},
MaxResponseMB: 25,
RequiredIconPacks: []string{"tabler"},
PublicRoutes: []core.ModPublicRoute{
{Path: "/area", Prefix: true},
{Path: "/api/semantic-search"},
},
Sitemap: true,
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
@ -401,6 +406,14 @@ func TestWriteMod_RoundTripsFirstClassFields(t *testing.T) {
if got := back.Plugin.RequiredIconPacks; len(got) != 1 || got[0] != "tabler" {
t.Errorf("required_icon_packs not preserved: %v\n%s", got, data)
}
if got := back.Plugin.PublicRoutes; len(got) != 2 ||
got[0] != (core.ModPublicRoute{Path: "/area", Prefix: true}) ||
got[1] != (core.ModPublicRoute{Path: "/api/semantic-search"}) {
t.Errorf("public_routes not preserved: %v\n%s", got, data)
}
if !back.Plugin.Sitemap {
t.Errorf("sitemap not preserved\n%s", data)
}
}
func TestParsePrivateCoord(t *testing.T) {

2
go.mod
View File

@ -5,7 +5,7 @@ go 1.26.4
require (
connectrpc.com/connect v1.20.0
git.dev.alexdunmow.com/block/core v0.18.2
git.dev.alexdunmow.com/block/pluginsdk v0.2.5
git.dev.alexdunmow.com/block/pluginsdk v0.2.7
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/klauspost/compress v1.18.6

4
go.sum
View File

@ -2,8 +2,8 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
git.dev.alexdunmow.com/block/core v0.18.2 h1:+3OfZ424yoc1k1CucXYARk8TBkh8Z693HRkhf5Ci2wU=
git.dev.alexdunmow.com/block/core v0.18.2/go.mod h1:GGuUu826AoJepC/hKLGJ7BX3PQaDss9ueCT0se6Ao2w=
git.dev.alexdunmow.com/block/pluginsdk v0.2.5 h1:01d+5stAywSENScafnNKyMc2ZM5GGFt+hCBKuKTFJW4=
git.dev.alexdunmow.com/block/pluginsdk v0.2.5/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
git.dev.alexdunmow.com/block/pluginsdk v0.2.7 h1:iL6Qvg2xHqHtii/0ZF9TIjjDzMrYkmxvLwqU300nmac=
git.dev.alexdunmow.com/block/pluginsdk v0.2.7/go.mod h1:Z+eG+WZxAP0jfreLqlGcc0kkWKt8RWevzWyWn8d+dhM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=

View File

@ -139,7 +139,16 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
manifest.AllowedHosts = mod.Plugin.AllowedHosts
manifest.MaxResponseMb = mod.Plugin.MaxResponseMB
// 4b. Mixed-form artifacts (WO-WZ-021): a reduced wasm plugin may keep a
// 4b. Stamp the public-route claims (ADR 0026, cms repo). Same
// mod-is-the-source rule as data_dir/allowed_hosts; validate here so a
// bad claim fails the build, not the install. Conflict resolution
// (core wins, first-installed plugin wins) is host-side: it needs the
// instance's install set, which the packer lacks.
if err := stampPublicRoutes(manifest, mod); err != nil {
return nil, err
}
// 4c. Mixed-form artifacts (WO-WZ-021): a reduced wasm plugin may keep a
// minimal guest for genuine logic (a contact route, Stripe) while its
// declarative surfaces (theme presets, bundled fonts, master pages,
// system/page templates, template overrides, email wrappers, css,
@ -243,6 +252,29 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
}, nil
}
// stampPublicRoutes validates the plugin.mod public-route declaration and
// copies it into the manifest (ADR 0026, cms repo). Routes and the sitemap
// flag are served by the guest HTTP handler, so declaring either without one
// fails the build.
func stampPublicRoutes(manifest *abiv1.PluginManifest, mod *core.ModFile) error {
if err := core.ValidatePublicRoutes(mod.Plugin.PublicRoutes); err != nil {
return fmt.Errorf("public_routes: %w", err)
}
if !manifest.GetHasHttpHandler() {
if len(mod.Plugin.PublicRoutes) > 0 {
return fmt.Errorf("public_routes declared but the plugin registers no HTTP handler to serve them")
}
if mod.Plugin.Sitemap {
return fmt.Errorf("sitemap = true but the plugin registers no HTTP handler to serve the sitemap endpoint")
}
}
for _, r := range mod.Plugin.PublicRoutes {
manifest.PublicRoutes = append(manifest.PublicRoutes, &abiv1.PublicRoute{Path: r.Path, Prefix: r.Prefix})
}
manifest.Sitemap = mod.Plugin.Sitemap
return nil
}
// buildWasm runs the reactor-mode wasip1 c-shared build in dir.
func buildWasm(ctx context.Context, dir, outPath string) error {
cmd := exec.CommandContext(ctx, "go", "build", "-buildmode=c-shared", "-o", outPath, ".")

View File

@ -0,0 +1,73 @@
package bnp
import (
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
core "git.dev.alexdunmow.com/block/pluginsdk/plugin"
)
func TestStampPublicRoutes(t *testing.T) {
manifest := &abiv1.PluginManifest{HasHttpHandler: true}
mod := &core.ModFile{Plugin: core.ModPlugin{
PublicRoutes: []core.ModPublicRoute{
{Path: "/area", Prefix: true},
{Path: "/api/semantic-search"},
},
Sitemap: true,
}}
if err := stampPublicRoutes(manifest, mod); err != nil {
t.Fatalf("stampPublicRoutes: %v", err)
}
routes := manifest.GetPublicRoutes()
if len(routes) != 2 {
t.Fatalf("manifest routes len = %d, want 2", len(routes))
}
if routes[0].GetPath() != "/area" || !routes[0].GetPrefix() {
t.Errorf("routes[0] = %+v, want /area prefix", routes[0])
}
if routes[1].GetPath() != "/api/semantic-search" || routes[1].GetPrefix() {
t.Errorf("routes[1] = %+v, want exact /api/semantic-search", routes[1])
}
if !manifest.GetSitemap() {
t.Errorf("manifest sitemap not stamped")
}
}
func TestStampPublicRoutes_RejectsReservedPrefix(t *testing.T) {
manifest := &abiv1.PluginManifest{HasHttpHandler: true}
mod := &core.ModFile{Plugin: core.ModPlugin{
PublicRoutes: []core.ModPublicRoute{{Path: "/api/plugins/mine", Prefix: true}},
}}
err := stampPublicRoutes(manifest, mod)
if err == nil {
t.Fatal("reserved prefix accepted; want error")
}
if !strings.Contains(err.Error(), "public_routes") || !strings.Contains(err.Error(), "reserved prefix") {
t.Errorf("error %q should name public_routes and the reserved prefix", err.Error())
}
}
func TestStampPublicRoutes_RequiresHTTPHandler(t *testing.T) {
mod := &core.ModFile{Plugin: core.ModPlugin{
PublicRoutes: []core.ModPublicRoute{{Path: "/area"}},
}}
if err := stampPublicRoutes(&abiv1.PluginManifest{}, mod); err == nil {
t.Error("public_routes without HTTP handler accepted; want error")
}
sitemapOnly := &core.ModFile{Plugin: core.ModPlugin{Sitemap: true}}
if err := stampPublicRoutes(&abiv1.PluginManifest{}, sitemapOnly); err == nil {
t.Error("sitemap without HTTP handler accepted; want error")
}
}
func TestStampPublicRoutes_NoDeclarationIsNoop(t *testing.T) {
manifest := &abiv1.PluginManifest{}
if err := stampPublicRoutes(manifest, &core.ModFile{}); err != nil {
t.Fatalf("empty declaration: %v", err)
}
if len(manifest.GetPublicRoutes()) != 0 || manifest.GetSitemap() {
t.Errorf("manifest gained routes from an empty mod: %+v", manifest)
}
}

View File

@ -193,6 +193,9 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
if mod.Plugin.DataDir {
return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it")
}
if len(mod.Plugin.PublicRoutes) > 0 || mod.Plugin.Sitemap {
return nil, fmt.Errorf("codeless plugin cannot declare public_routes or sitemap: there is no HTTP handler to serve them")
}
manifest := &abiv1.PluginManifest{
AbiVersion: hostAbiVersion,

View File

@ -135,6 +135,13 @@ func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
{"data_dir grant", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n",
}},
{"public_routes claim", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n" +
"public_routes = [{ path = \"/area\", prefix = true }]\n",
}},
{"sitemap flag", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\nsitemap = true\n",
}},
{"override missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",