diff --git a/.planning/phases/32-seo-foundation/32-05-demo-mount.patch b/.planning/phases/32-seo-foundation/32-05-demo-mount.patch new file mode 100644 index 00000000..116f9fc0 --- /dev/null +++ b/.planning/phases/32-seo-foundation/32-05-demo-mount.patch @@ -0,0 +1,324 @@ +32-05 demo mount of golem15.seo (sm-seo-plugin), proven in a scratch worktree +of sm-demo-app at 8fcdbdd. Plan 32-06 applies it after the plugin and +framework tags, from the sm-demo-app root: + + git apply .../32-05-demo-mount.patch + +and then, in the same release commit or separate commits as DEPLOY.md +describes: add the plugin submodule at plugins/golem15/seo +(.gitmodules, branch main), add ./plugins/golem15/seo to go.work, require +the tagged git.golem15.com/golem15/sm-seo-plugin (and the tagged +sm-journal-plugin with the 32-02 descriptors) in go.mod, run summer build +(plugins.gen.go below is what it generates), and pass TestGoModRequiresTags. + +The patch adds the plugin to summer.yaml after golem15.sitemap, the +plugin config, the nginx /robots.txt location, the DEPLOY.md section, the +plugin in TestBootAllPlugins, and the two tests TestSEOTabMounted and +TestSEOToolsCatalog (summercms_mcp), which skip with "NOT MOUNTED +golem15.seo" while summer.yaml does not list the plugin. + +Scratch run (GOWORK with ../sm-journal-plugin and ../sm-seo-plugin): + --- PASS: TestBootAllPlugins + --- PASS: TestSEOTabMounted + --- PASS: TestSEOToolsCatalog + +diff --git a/DEPLOY.md b/DEPLOY.md +index edc10eb..a964219 100644 +--- a/DEPLOY.md ++++ b/DEPLOY.md +@@ -407,6 +407,26 @@ and add `http://127.0.0.1:3020/mcp` to the client. With the default `app.url` (` + the endpoint answers on the Go port, but the consent redirect goes to `/connect` on Go, which has no such + page. + ++### SEO (golem15.seo) ++ ++The demo mounts sm-seo-plugin: an SEO tab on Journal posts, categories and tags, URL rules, redirects, ++the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (the administrator needs ++`golem15.seo.manage`). `config/golem15.seo.yaml` sets `site_url` to `https://demo.summercms.io`. ++ ++- **robots.txt moves to Go.** nginx sends `= /robots.txt` to the Go port (`deploy/nginx/demo.summercms.io.conf`); ++ the Nuxt robots module no longer answers it. Before the first release with the plugin, copy the ++ `Disallow` lines the Nuxt config writes today (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`, ++ `/_journal`, `/_menu`, `/_faq`, `/_paymentgateway`, `/_paymentgatewaysubscriptions`, `/_feedback`, ++ `/storage`, `/_presentations`, `/_quizzes`, `/_demo`, `/api/realtime/token`, `/mcp`, ++ `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/`) into ++ **SEO › robots.txt** under `User-agent: *`, and add `/_seo`. The `Sitemap:` line is added by the plugin. ++ Outside production the plugin serves `Disallow: /` whatever is stored. ++- **404 log.** Set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` in `.env` on rome (a random value, never printed ++ or committed) and the same token in the Nuxt site's environment once the frontend layer reports 404s ++ (Phase 33); without it the report endpoint stays off. ++- **Verify** after the release: `curl -s https://demo.summercms.io/robots.txt` shows the copied lines and ++ the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`. ++ + ## Build + + ```sh +diff --git a/boot_test.go b/boot_test.go +index ca053ea..7eba15b 100644 +--- a/boot_test.go ++++ b/boot_test.go +@@ -19,7 +19,7 @@ import ( + + // wantPlugins is every plugin of the demo, in summer.yaml order. + var wantPlugins = []string{ +- "golem15.user", "golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.menu", ++ "golem15.user", "golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.seo", "golem15.menu", + "golem15.faq", "golem15.paymentgateway", "golem15.pgstripe", "golem15.paymentgatewaysubscriptions", + "golem15.pgstripesubscriptions", "golem15.presentations", "golem15.quizzes", "golem15.golem", + "golem15.feedback", "golem15.oauthserver", "golem15.sociallogin", "golem15.mfa", "golem15.demo", +@@ -59,6 +59,7 @@ func TestBootAllPlugins(t *testing.T) { + "/_user/api/v1", "/_journal/api/v1", "/_menu/api/v1", "/_faq/api/v1", + "/_paymentgatewaysubscriptions/api/v1", "/webhook/stripe/subscriptions", "/sitemap.xml", "/smbackend", "/_demo/api/v1", "/_demo/api/v1/presentations", + "/oauth/", "/_user/api/v1/oauth-complete", "/_user/api/v1/2fa/", "/_user/api/v1/devices", ++ "/_seo/api/v1", "/robots.txt", + } { + if !routed(rt.Routes(), prefix) { + t.Errorf("no route under %s", prefix) +diff --git a/config/golem15.seo.yaml b/config/golem15.seo.yaml +new file mode 100644 +index 0000000..dbea376 +--- /dev/null ++++ b/config/golem15.seo.yaml +@@ -0,0 +1,12 @@ ++# golem15.seo (sm-seo-plugin): SEO of the Journal posts, categories and tags, ++# URL rules, redirects, the 404 log, robots.txt and the SEO MCP tools. ++# ++# site_url is the public origin the Nuxt site serves; canonical URLs, ++# Open Graph URLs and robots.txt Sitemap lines are built from it, never from ++# a request's Host. The 404 report token comes from the environment ++# (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN, .env on rome; never committed); ++# without it the report endpoint is off. ++site_url: https://demo.summercms.io ++notfound_token: "" ++robots_sitemaps: ++ - /sitemap.xml +diff --git a/deploy/nginx/demo.summercms.io.conf b/deploy/nginx/demo.summercms.io.conf +index 9b8aaa1..c179b49 100644 +--- a/deploy/nginx/demo.summercms.io.conf ++++ b/deploy/nginx/demo.summercms.io.conf +@@ -299,6 +299,17 @@ server { + proxy_set_header X-Forwarded-Proto $scheme; + } + ++ # robots.txt comes from golem15.seo: the rules and AI bot switches of ++ # SEO › robots.txt in production, the lock (Disallow: /) elsewhere. ++ location = /robots.txt { ++ proxy_pass http://127.0.0.1:8098; ++ proxy_http_version 1.1; ++ proxy_set_header Host $host; ++ proxy_set_header X-Real-IP $remote_addr; ++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ++ proxy_set_header X-Forwarded-Proto $scheme; ++ } ++ + # The sitemap plugin's document and its stylesheet. + location = /sitemap.xml { + proxy_pass http://127.0.0.1:8098; +diff --git a/plugins.gen.go b/plugins.gen.go +index b13599f..a4cd28f 100644 +--- a/plugins.gen.go ++++ b/plugins.gen.go +@@ -17,6 +17,7 @@ import ( + _ "git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions" + _ "git.golem15.com/golem15/sm-presentations-plugin" + _ "git.golem15.com/golem15/sm-quizzes-plugin" ++ _ "git.golem15.com/golem15/sm-seo-plugin" + _ "git.golem15.com/golem15/sm-sitemap-plugin" + _ "git.golem15.com/golem15/sm-sociallogin-plugin" + _ "git.golem15.com/golem15/sm-translate-plugin" +@@ -29,6 +30,7 @@ var PluginIDs = []string{ + "golem15.translate", + "golem15.journal", + "golem15.sitemap", ++ "golem15.seo", + "golem15.menu", + "golem15.faq", + "golem15.paymentgateway", +diff --git a/seo_mcp_test.go b/seo_mcp_test.go +new file mode 100644 +index 0000000..5ac56cc +--- /dev/null ++++ b/seo_mcp_test.go +@@ -0,0 +1,54 @@ ++//go:build summercms_mcp ++ ++package main ++ ++import ( ++ "net/http" ++ "slices" ++ "testing" ++ ++ "git.golem15.com/golem15/summercms/modules/pact" ++) ++ ++// TestSEOToolsCatalog checks SC-4 through the demo's MCP endpoint: an ++// administrator with golem15.seo.manage, linked to the MCP user with the ++// backend scope, is listed seo_ plus every tool the registered SEO plugin ++// declares (pier binds each to its route at boot); a read grant lists the ++// GET tools only. The plugin is found through the activated plugins and ++// pact.HasMCP, so the demo imports no SEO package. ++func TestSEOToolsCatalog(t *testing.T) { ++ skipUnlessSEOMounted(t) ++ env := bootMCPDemo(t) ++ var tools []pact.MCPTool ++ for _, p := range env.demo.Plugins { ++ if p.ID() != seoPluginID { ++ continue ++ } ++ hm, ok := p.(pact.HasMCP) ++ if !ok { ++ t.Fatalf("%s declares no MCP tools", seoPluginID) ++ } ++ tools = hm.MCP().Tools ++ } ++ if len(tools) == 0 { ++ t.Fatalf("%s is mounted but activated no tools", seoPluginID) ++ } ++ editor := env.frontendUser(t, "seo") ++ env.link(t, editor.ID, env.editorAdmin(t, "seo", "golem15.seo.manage")) ++ client := env.mcpClient(t, "demo-seo-agent") ++ ++ names := toolNames(t, env.connect(t, env.mint(t, editor.ID, client, "read", "write", "backend"))) ++ for _, tool := range tools { ++ if !hasTool(names, "seo_"+tool.Name) { ++ t.Errorf("catalog %v lacks seo_%s", names, tool.Name) ++ } ++ } ++ ++ reads := toolNames(t, env.connect(t, env.mint(t, editor.ID, client, "read", "backend"))) ++ for _, tool := range tools { ++ listed := slices.Contains(reads, "seo_"+tool.Name) ++ if get := tool.Method == http.MethodGet; listed != get { ++ t.Errorf("read grant: seo_%s listed %v, want %v (method %s)", tool.Name, listed, get, tool.Method) ++ } ++ } ++} +diff --git a/seo_mount_test.go b/seo_mount_test.go +new file mode 100644 +index 0000000..9c08cc0 +--- /dev/null ++++ b/seo_mount_test.go +@@ -0,0 +1,109 @@ ++package main ++ ++import ( ++ "encoding/json" ++ "net/http" ++ "net/http/httptest" ++ "slices" ++ "strconv" ++ "testing" ++ "time" ++ ++ "git.golem15.com/golem15/sm-demo-app/internal/demotest" ++ "git.golem15.com/golem15/summercms/modules/bouncer" ++ "git.golem15.com/golem15/summercms/modules/cabana" ++ "git.golem15.com/golem15/summercms/modules/surf" ++) ++ ++// seoPluginID is the SEO plugin. The demo mounts it once the plugin is ++// tagged; until then these checks skip, and they import no SEO package. ++const seoPluginID = "golem15.seo" ++ ++// skipUnlessSEOMounted skips a test while summer.yaml does not list the ++// SEO plugin; the phase gate reads the NOT MOUNTED line. ++func skipUnlessSEOMounted(t *testing.T) { ++ t.Helper() ++ if !slices.Contains(PluginIDs, seoPluginID) { ++ t.Skip("NOT MOUNTED " + seoPluginID) ++ } ++} ++ ++// tabOrder lists a form schema's tabs in the order the admin shows them ++// (first appearance; fields without a tab are the form's outside area). ++func tabOrder(fields []struct { ++ Name string `json:"name"` ++ Tab string `json:"tab"` ++}) []string { ++ var out []string ++ for _, f := range fields { ++ if f.Tab != "" && !slices.Contains(out, f.Tab) { ++ out = append(out, f.Tab) ++ } ++ } ++ return out ++} ++ ++// TestSEOTabMounted checks the SEO tab on the Journal forms (SC-2): posts, ++// categories and tags get the SEO tab last, and the post form keeps its ++// own tabs first (Edit, Manage, Categories, SEO). ++func TestSEOTabMounted(t *testing.T) { ++ skipUnlessSEOMounted(t) ++ demo := bootDemo(t, nil, nil) ++ h, err := surf.Assemble(demo.App, demo.Plugins) ++ if err != nil { ++ t.Fatalf("surf.Assemble: %v", err) ++ } ++ prefix, err := cabana.AdminPrefix(demo.App) ++ if err != nil { ++ t.Fatal(err) ++ } ++ var adminID uint ++ stamp := strconv.FormatInt(time.Now().UnixNano(), 36) ++ if err := demo.DB.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at) ++VALUES (?, ?, 'x', 'Seo', 'Check', TRUE, TRUE, NOW(), NOW()) RETURNING id`, "seo-"+stamp, "seo-"+stamp+"@example.test").Scan(&adminID).Error; err != nil || adminID == 0 { ++ t.Fatalf("backend user: id=%d err=%v", adminID, err) ++ } ++ token, _, err := bouncer.MintAudience(demotest.AdminJWTSecret, strconv.FormatUint(uint64(adminID), 10), "http://localhost"+prefix+"/api/v1/auth/login", time.Hour, bouncer.AudienceBackend) ++ if err != nil { ++ t.Fatal(err) ++ } ++ schema := func(controller string) []string { ++ t.Helper() ++ req := httptest.NewRequest(http.MethodGet, prefix+"/api/v1/golem15/journal/"+controller+"/schema/form", nil) ++ req.Header.Set("Authorization", "Bearer "+token) ++ req.Header.Set("Accept-Language", "en") ++ rec := httptest.NewRecorder() ++ h.ServeHTTP(rec, req) ++ if rec.Code != http.StatusOK { ++ t.Fatalf("%s schema = %d %s", controller, rec.Code, rec.Body.String()) ++ } ++ var out struct { ++ Data struct { ++ Fields []struct { ++ Name string `json:"name"` ++ Tab string `json:"tab"` ++ } `json:"fields"` ++ } `json:"data"` ++ } ++ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { ++ t.Fatal(err) ++ } ++ fields := out.Data.Fields ++ if len(fields) == 0 || fields[len(fields)-1].Tab != "SEO" { ++ t.Fatalf("%s: last field %+v, want the SEO tab last", controller, fields) ++ } ++ found := false ++ for _, f := range fields { ++ found = found || f.Name == "seo_title" ++ } ++ if !found { ++ t.Fatalf("%s: no seo_title field", controller) ++ } ++ return tabOrder(fields) ++ } ++ if got, want := schema("posts"), []string{"Edit", "Manage", "Categories", "SEO"}; !slices.Equal(got, want) { ++ t.Fatalf("post tabs = %v, want %v", got, want) ++ } ++ schema("categories") ++ schema("tags") ++} +diff --git a/summer.yaml b/summer.yaml +index 886edfb..13a4d49 100644 +--- a/summer.yaml ++++ b/summer.yaml +@@ -13,6 +13,8 @@ plugins: + module: git.golem15.com/golem15/sm-journal-plugin + - id: golem15.sitemap + module: git.golem15.com/golem15/sm-sitemap-plugin ++ - id: golem15.seo ++ module: git.golem15.com/golem15/sm-seo-plugin + - id: golem15.menu + module: git.golem15.com/golem15/sm-menu-plugin + - id: golem15.faq