diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index c39825f..dc666e9 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -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) diff --git a/cmd/ninja/cmd/plugin_test.go b/cmd/ninja/cmd/plugin_test.go index 8020c49..866ef3b 100644 --- a/cmd/ninja/cmd/plugin_test.go +++ b/cmd/ninja/cmd/plugin_test.go @@ -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) { diff --git a/go.mod b/go.mod index d6b80ae..98a1bc4 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 05a33e3..0a6ff53 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/bnp/build.go b/internal/bnp/build.go index 96911c3..ebf24c4 100644 --- a/internal/bnp/build.go +++ b/internal/bnp/build.go @@ -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, ".") diff --git a/internal/bnp/build_test.go b/internal/bnp/build_test.go new file mode 100644 index 0000000..70f3753 --- /dev/null +++ b/internal/bnp/build_test.go @@ -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) + } +} diff --git a/internal/bnp/codeless.go b/internal/bnp/codeless.go index 9c1131d..3d4ca82 100644 --- a/internal/bnp/codeless.go +++ b/internal/bnp/codeless.go @@ -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, diff --git a/internal/bnp/codeless_test.go b/internal/bnp/codeless_test.go index 9c111a4..2755835 100644 --- a/internal/bnp/codeless_test.go +++ b/internal/bnp/codeless_test.go @@ -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",