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:
parent
4eb4bcfc50
commit
71d005f1fb
@ -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)
|
||||
|
||||
@ -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
2
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@ -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, ".")
|
||||
|
||||
73
internal/bnp/build_test.go
Normal file
73
internal/bnp/build_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user