Compare commits

..

44 Commits

Author SHA1 Message Date
9094bf9ad9 test(32.1-05): prove MCP call context, both guards, and PRM scopes
- A bare context and a plain request carry no MCP call
- Frontend and backend tool calls record the guard that owns the tool
- PRM uses an advertised scope list and falls back to PRMScopes
2026-10-28 16:09:30 +01:00
06a144e19f docs(32.1-04): complete vue-oauth-layer /connect plan 2026-10-28 16:00:25 +01:00
23238ccd47 docs(32.1-03): complete MCP consent and backfill plan 2026-10-28 15:44:26 +01:00
3f52b1ab49 docs(32.1-02): complete OAuth implied scopes plan 2026-10-28 15:06:32 +01:00
415a09333b docs(32.1-01): complete MCP scope list and call context plan
- Record the plan summary
- Mark 32.1-01 executed on the roadmap
2026-10-28 14:42:59 +01:00
876f0f09f2 docs(32.1-01): document the MCP scope list and call context
- READMEs and service pages name the new pact and wristband identifiers
- an empty MCP scope list resolves to the five fixed names; OIDC discovery is unchanged
2026-10-28 14:36:50 +01:00
9e29d6ff9b feat(32.1-01): expose offline access on the pending consent view
- PendingRequestView.OfflineAccess is the pending row's flag
- IssueCode's signature is unchanged
2026-10-28 14:33:11 +01:00
63d05e8acb feat(32.1-01): publish one MCP scope list and call context
- pact gains MemberTokenScopeCatalog, MCPAdvertisedScopes and MCPCall
- an empty MCP ScopesSupported resolves to the five fixed names including backend
- pier copies advertised scopes and sets MCPCall beside WithDispatch
2026-10-28 14:32:31 +01:00
2cea3127cc docs(33): roadmap plan list after revision 2026-10-28 14:25:11 +01:00
8416f10849 docs(33): revise phase plan after checker 2026-10-28 14:23:02 +01:00
200c664a4b docs(32.1): create phase plan 2026-10-28 14:19:09 +01:00
41d145dde1 docs(phase-19.6): add security threat verification 2026-10-28 14:03:50 +01:00
f5931d5698 docs(phase-19): add security threat verification 2026-10-28 14:01:30 +01:00
3938ebbddd docs(phase-16.2): add security threat verification 2026-10-28 13:59:44 +01:00
1173f35f80 docs(33): record D-70, SC-3, H1 scope and research defaults; resolve open questions 2026-10-28 13:56:00 +01:00
88fbfb7b39 docs(33): create phase plan
Fourteen plans in nine waves for SEO output, audit and content, with the
pattern map and the IndexNow coverage matrix.

Claude-Session: https://claude.ai/code/session_013jPFwWHBUFJPX91w5Qa4Vs
2026-10-28 13:48:06 +01:00
0db030598e docs(phase-32.1): add validation strategy 2026-10-28 13:38:02 +01:00
bd23a82f22 docs(todos): close FAQ port, site-cache purge, sitemap XSLT; drop sitemap port 2026-10-28 13:19:06 +01:00
a7df0caaa3 docs(32.1): add read/write implied scopes and vue-oauth-layer to context 2026-10-28 13:06:34 +01:00
14979e7009 docs(32.1): capture phase context 2026-10-28 13:02:54 +01:00
df2a0753c7 docs(33): record plan list, release cadence and WebP decision 2026-10-28 12:49:20 +01:00
2cc3c1728b docs(33): UI design contract 2026-10-28 12:38:04 +01:00
69d5a24fa8 docs(33): record per-site options for post title, new-tab links and journal breadcrumbs 2026-10-28 12:35:11 +01:00
2ac8663fe7 docs(33): make Journal's H1 conversion a per-site setting 2026-10-28 11:53:45 +01:00
126c0866fc docs(33): research and validation strategy for SEO output, audit and content 2026-10-28 11:39:03 +01:00
b8aa5d881c docs(quick-261028-fsq): sm-user-plugin MCPDispatch credential carries the personal token id 2026-10-28 11:31:35 +01:00
7c4dbb1252 docs(33): write out phase 33 context from the phase 32 SEO discussion 2026-10-27 20:10:32 +01:00
adf5cccc4a test(32): verification report and human verification items as UAT 2026-10-27 16:12:00 +01:00
6472f2861e docs(32): reconcile review disposition with the fix report 2026-10-27 15:47:24 +01:00
9fe489084c docs(32): record code review disposition 2026-10-27 15:47:24 +01:00
175e469587 docs(32): add code review fix report 2026-10-27 15:47:00 +01:00
0e187cfb6a fix(32): WR-07 close the relation and partial routes of hidden owner fields
Hide removed an owner field from the schema, show and save, but a hidden
relation-manager field's relation routes (panel, link, unlink, child CRUD,
deferred commit) and a hidden partial field's partial stayed reachable.
dropHiddenFields now drops the relation and the partial path too; a partial
the list or preview header or another field still uses keeps rendering.
2026-10-27 15:39:32 +01:00
1583651783 fix(32): WR-02 report the request locale's primary tag on record events
Admin saves copied the raw context locale into RecordSaved.Locale, which
could be a whole Accept-Language header, while reorder reported a
normalised code. Saves, child writes and reorder now report the primary tag
of the request locale. The pact doc says what Locale is and that per-locale
work keys on RecordChange.Locale, since an admin save writes the row in the
default content locale.
2026-10-27 15:32:11 +01:00
468af06c86 fix(32): WR-01 fire record events for hasMany link, unlink and deferred children
hasMany link and unlink rewrote a child's foreign key (and ClearOnChange
columns) without a RecordSaved, the deferred commit attached session-created
children silently, and unlinking a session-created child deleted it without
the RecordDeleted that should follow its RecordSaved{Created}. These paths
now fire the child's events after the commit; the CRUD service carries the
registry so the deferred commit names the child's controller.
2026-10-27 15:30:07 +01:00
d660cd05fa fix(32): CR-04 take widget actions dispatched from connectedCallback
WidgetField assigned its element after append(), so an action the element
dispatched from connectedCallback (inside append) found no target and was
dropped without a result or an error state; widgets that load on connect
never started. The element is now the action target before it is connected.
Rebuilt the embedded admin dist.
2026-10-27 15:25:24 +01:00
5471b66b8f fix(32): CR-02 gate field type uploads on extension visibility
The plugin field-type upload route resolved a form extension's field without
checking the extension's Permissions and Visible, so an administrator who
cannot see the extension could still run its Upload. The route now answers
404 for them, like the extension's widget actions and partials.
2026-10-27 15:22:21 +01:00
676ca539aa fix(32): CR-01 check required extension fields on the lifted value
A required: true field in a form extension became a rule on the owner's
model, where its value never exists, so every save of the host form failed,
also for administrators who cannot see the extension. mergedRules now skips
extension fields; liftExtensionValues checks required on the submitted value
for visible extensions only (create: must be sent and not blank; update:
only when sent).
2026-10-27 15:20:33 +01:00
2b7174d406 docs(32): number review findings and record their disposition 2026-10-27 14:31:55 +01:00
1259f5bd1d docs(32): record code review disposition 2026-10-27 14:31:50 +01:00
32141f803a docs(32): record code review disposition 2026-10-27 14:31:37 +01:00
8b4817fa0a docs(32): add code review report 2026-10-27 14:31:28 +01:00
cffa49642c docs(32-06): mark plan 32-06 executed in the roadmap 2026-10-27 14:18:39 +01:00
f098ffdbf9 docs(32-06): complete the unit tests and v0.5.1 release plan 2026-10-27 14:18:11 +01:00
a9a7f58eed fix(32-06): the additive stage compares with the previous release once HEAD is tagged
- last_tag skips tags that contain HEAD, so a post-release run compares
  v0.5.1 with v0.5.0 instead of with itself
2026-10-27 14:15:49 +01:00
98 changed files with 15404 additions and 105 deletions

View File

@@ -1595,7 +1595,7 @@ Plans:
4. Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history.
5. The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan.
**Plans:** 5/6 plans executed
**Plans:** 6/6 plans executed
Plans:
**Wave 1**
@@ -1614,7 +1614,36 @@ Plans:
- [x] 32-05-PLAN.md — /_seo/api/v1 management API and MCP tools, batch dry-run, undo, CSV, demo mount proof, check-phase32.sh
**Wave 6** *(blocked on Wave 5 completion)*
- [ ] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
- [x] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog
### Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent (INSERTED)
**Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
**Requirements**: TBD
**Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module)
**Plans:** 4/5 plans executed
**Success Criteria** (what must be TRUE):
1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`).
2. A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context through an accessor, so an application records the OAuth client of a write; a personal-token call's credential keeps carrying the token id (sm-user-plugin v0.1.14).
3. `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents.
4. Module READMEs and `docs/` pages describe the three changes; the framework and `sm-oauthserver-plugin` get patch tags.
Plans:
**Wave 1**
- [x] 32.1-01-PLAN.md — Framework seam: catalog, MCPCall, one advertised scope list
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 32.1-03-PLAN.md — sm-oauthserver-plugin: consent replace, revoke, backfill
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 32.1-04-PLAN.md — vue-oauth-layer /connect checkboxes
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
### Phase 33: SEO output, audit and content
@@ -1640,10 +1669,40 @@ Plans:
3. The crawler reports SEO issues per URL, and each issue can be fixed through MCP.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 14 plans
Plans:
- [ ] TBD (run /msd-plan-phase 33 to break down)
**Wave 1**
- [ ] 33-01-PLAN.md — framework: additive content descriptor fields and per-request field options, check-phase33.sh, v0.5.2 release log (no tag)
- [ ] 33-07-PLAN.md — vue-seo-layer (new repository): CMS head and one JSON-LD graph, redirect/410 enforcement, root files, 404 report, shared purge route
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 33-02-PLAN.md — sm-journal-plugin: per-site H1 setting on every write path, editor J1–J4, descriptor facts, journal-author type, MCP tools
- [ ] 33-03-PLAN.md — sm-sitemap-plugin: sitemap index with per-type files, slot table, real lastmod, hreflang, filter contract (breaking, D-32)
- [ ] 33-08-PLAN.md — vue-seo-layer: SSR build guard, noindex routes, route report, drift check, breadcrumbs, pagination, HTML sitemap
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 33-04-PLAN.md — sm-sitemap-plugin: image and News sitemaps, IndexNow, HTML-sitemap API, MCP tools
- [ ] 33-09-PLAN.md — journal, menu and FAQ layers on the SEO layer: headings in both H1 modes, one link per card, body links, breadcrumbs, pagination, related posts
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 33-05-PLAN.md — sm-seo-plugin I: sitemap filter, page registry with route report, site endpoint, llms.txt, Links settings, purge after writes
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 33-06-PLAN.md — sm-seo-plugin II: JSON-LD @graph, publisher, schema fields (E8/E9), breadcrumbs, hreflang, pagination; chromedp and webp legitimacy checkpoint
**Wave 6** *(blocked on Wave 5 completion)*
- [ ] 33-10-PLAN.md — sm-seo-plugin: crawler with optional rendered comparison (chromedp), issue report with fix routes, Site audit and Crawls, MCP tools
**Wave 7** *(blocked on Wave 6 completion)*
- [ ] 33-11-PLAN.md — internal linking and media: auto-links, link suggestions (E10), WebP variants with srcset
**Wave 8** *(blocked on Wave 7 completion)*
- [ ] 33-12-PLAN.md — apps I: demo end to end on local links, summercms.io patches, sm-bm-app noindex (D-72)
- [ ] 33-13-PLAN.md — grzybyfunkcjonalne.pl on the new sitemap format and the SEO layer; uat deploy by decision
**Wave 9** *(blocked on Wave 8 completion)*
- [ ] 33-14-PLAN.md — unit tests, check-phase33.sh --all, release tags in order, app re-pins, changelog post
## Progress
@@ -1698,8 +1757,8 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
| 30. Płytarium cutover | 0/TBD | Not started | - |
| 31. Chat polish against WavePath clients | 0/TBD | Not started | - |
| 32. SEO foundation | 5/6 | In Progress| |
| 33. SEO output, audit and content | 0/TBD | Not started | - |
| 32. SEO foundation | 6/6 | In Progress| |
| 33. SEO output, audit and content | 0/14 | Planned | - |
## Backlog

View File

@@ -0,0 +1,131 @@
---
phase: "16.2"
slug: "plugin-provided-field-types-and-the-blog-editor-formwidget"
status: verified
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
threats_open: 0
asvs_level: 1
created: "2026-10-28"
---
# Phase 16.2 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
Paths are relative to the meta repo (`summercms.go/`, `sm-journal-plugin/`, `vue-journal-layer/`, `sm-grzybyfunkcjonalne-app/`). Code was checked at each repo's HEAD; for code that later moved (the site-cache purger now lives in `summercms.go/modules/cooler`), the 16.2 release commit `5eea57f` was checked too.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| admin browser → cabana admin API | Save bodies, upload bodies and field-type requests (cookie or Bearer session) | field values in every locale, uploaded files |
| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and save transaction | decoded values, plugin errors and notices |
| plugin element JS → admin SPA DOM | The element runs in the admin origin under CSP `script-src 'self'` | `value` property, plain attributes, `summer-upload` events |
| stored post HTML → admin editor DOM | Imported or previously saved HTML is parsed into the editor | untrusted HTML |
| admin save body → Journal Decode | Every locale's content value crosses here through cabana | HTML to sanitize |
| editor upload → media bucket | User files become public URLs under `/storage/app/uploads/public` | image bytes, object keys |
| Journal API `content_html` → site `v-html` | Stored HTML becomes page markup | HTML, embed placeholders |
| site page → youtube-nocookie.com | The visitor's browser loads a third-party frame | referrer, visitor IP |
| Go host → Nuxt (loopback HTTP) | Journal calls the layer purge route with a bearer token | purge token, slug |
| internet → nginx → Nuxt | Visitors reach Nuxt with GET/HEAD only; nginx adds forwarding headers | requests, X-Forwarded-For |
| npm registry → plugin build | `@tiptap/*` packages enter the committed bundle | third-party code |
| executor → git origin / UAT server | Tags and pushes read by other repos; release runs next to the live Winter site | tags, binaries, `.env` secrets (server only) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | `summercms.go/modules/cabana/field_type.go:106` refuses core aliases, `:166-171` refuses a cross-plugin alias naming both owners, `:172` checkWidgetTag (prefix check `extension.go:346`) | closed |
| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | `crud.go:830-852` runs it inside the save transaction; `field_type.go:465/489/511` decode host value and every locale; only skip is the served-value match `:539-541`, which writes nothing | closed |
| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | `http.go:452` requireAjax; `field_type_upload.go:35` protect (guard and permissions `http.go:1216-1225`), `:49/:142-160` field/context check and 404 without Upload, `:124` loadRecord scope; `auth.go:253` SameSite=Strict | closed |
| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | `field_type_upload.go:69` MaxBytesReader on `upload_bytes`, `:85/:168-177` exactly one part, `:200-204` 413 | closed |
| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | `admin/src/components/form/fields/PluginField.vue:63-65,76-84` value and plain attributes only, upload via SPA `api.POST` `:133`; test `admin/tests/form/PluginField.test.ts:103` | closed |
| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | `PluginField.vue:44,81` JSON attribute string; no `v-html` | closed |
| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | `field_type.go:669-686` only `*pact.FormFieldError` → 422; `:690-693` and `:727-729` opaque 500 | closed |
| T-16.2-08 | Tampering | untouched skip | low | accept | See AR-16.2-01 | closed |
| T-16.2-33 | Tampering (admin XSS) / DoS | save notices | medium | mitigate | `admin/src/components/form/FormField.vue:86`, `ui/Toast.vue:34` text interpolation only; `field_type.go:40-42,799,811` 10 notices per field/locale, 500 runes; test `FormField.test.ts:50` | closed |
| T-16.2-09 | Elevation of Privilege (admin XSS) | rawHtml node view | high | mitigate | `sm-journal-plugin/assets/src/blogeditor/extensions.ts:301,315` textContent; parse in inert document `:270-273`; test `tests/blogeditor/smoke.test.ts:135` | closed |
| T-16.2-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | `classes/blog_editor.go:164` SanitizeHTMLReport on every locale; admin `FormView.vue:478` adopts the stored value; layer `PostBody.vue:13` sanitizes again | closed |
| T-16.2-11 | Tampering | link mark | medium | mitigate | `extensions.ts:42-63` isPolicyUrl (control chars removed first), `:545`; `element.ts:465,570`; tests `smoke.test.ts:417-424` | closed |
| T-16.2-12 | Elevation of Privilege | bundle under admin CSP | medium | mitigate | no `eval(`/`new Function(` in `assets/dist/blogeditor.js`; `element.ts:202` `injectCSS: false`; CSS scoped under `golem15-journal-blogeditor` | closed |
| T-16.2-13 | Information Disclosure | uploads | high | mitigate | `element.ts:559` dispatches `summer-upload {id, file}` only; no fetch, XHR, cookie or storage access in `assets/src/blogeditor` | closed |
| T-16.2-14 | Tampering (stored XSS) | decodeBlogHTML (posts, categories) | high | mitigate | `blog_editor.go:154-166`; `models/post/fields.yaml:19`, `models/category/fields.yaml:28`; tests `posts_admin_smoke_test.go:360`, `categories_admin_editor_test.go:56` | closed |
| T-16.2-31 | Tampering (stored XSS) | newHTMLPolicy editor-output extension | high | mitigate | `classes/format_html.go:63-72`, exactly four AllowAttrs added since `c60abc6`; 16.1 policy tests unchanged; `editor_policy_test.go:68` | closed |
| T-16.2-34 | Denial of Service | SanitizeHTMLReport and removal notices | low | mitigate | `format_html.go:105` (`maxRemovalNames = 10`), `:145-153`; cabana cap (T-16.2-33) | closed |
| T-16.2-15 | Tampering / Elevation | uploadBlogImage | high | mitigate | `media_store.go:89-93` allowlist without svg, `:20,98-104` 10240 KiB cap; `blog_editor.go:365-375` DetectContentType; staging vhost `:89` nosniff | closed |
| T-16.2-16 | Tampering | upload keys | medium | mitigate | `blog_editor.go:129-147` folder check at boot, `:397-426` safeUploadName; `media_store.go:155,193-224` never overwrites | closed |
| T-16.2-17 | Repudiation / contract | public posts and media routes | medium | mitigate | `controllers/api/posts_helpers.go`, `posts.go`, `routes.go` unchanged `c60abc6..5eea57f`; `api/media.go:43-63` keeps body and errors | closed |
| T-16.2-18 | Information Disclosure | content_html companion writes | low | mitigate | `controllers/posts.go:216-224,271-308` written locales only, in the transaction | closed |
| T-16.2-19 | Tampering (iframe injection) | expandEmbeds | high | mitigate | `vue-journal-layer/app/utils/embeds.ts:41` exact regex, `:51` provider allowlist, `:53` id pattern, `:29` fixed host | closed |
| T-16.2-20 | Elevation of Privilege | sanitize-then-expand order | high | mitigate | `Journal/PostBody.vue:13`, `CategoryHeader.vue:11` `expandEmbeds(sanitizeJournalHtml(...))`, no other caller; gate `check-phase16.2.sh:123` | closed |
| T-16.2-32 | Tampering (policy drift) | htmlGate.ts mirror | high | mitigate | `app/utils/htmlGate.ts:68-71,122-127`, only these rules since `294a8d8`; corpus copies identical (`cmp`) | closed |
| T-16.2-21 | Information Disclosure (visitor privacy) | YouTube frame | low | mitigate | `embeds.ts:29,57-58` nocookie host, lazy, strict-origin-when-cross-origin; staging vhost `:93` frame-src (report-only; server install pending, WINDOWS 19) | closed |
| T-16.2-22 | Tampering / Repudiation | layer tag and pushes | medium | mitigate | v0.2.0 annotated (`042b65d → 759e684`); `scripts/check-git-install.sh`; 04-SUMMARY records `check-git-install: ok` and pushed heads | closed |
| T-16.2-23 | Tampering (false-green release) | release gate | medium | mitigate | 05-SUMMARY `release-gate-ok`, Docker-only suites listed, human reply "release v0.1.18" | closed |
| T-16.2-24 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | v0.1.18 annotated (`04dc5bd → f1d8a75`), contains plan 01 head `4adb48a`; binary sha256 matched; heads on origin | closed |
| T-16.2-25 | Information Disclosure | credentials | high | mitigate | `sm-grzybyfunkcjonalne-app/DEPLOY.md:74` no `.env` rsync; no env file tracked; `.gitignore:5`; 05-SUMMARY no auth gates | closed |
| T-16.2-26 | Tampering | UAT database and Winter site | medium | mitigate | 05-SUMMARY: SELECT-only checks, `.prev` rollback copies kept | closed |
| T-16.2-35 | Tampering | UAT strip check | low | mitigate | Strip verified (05-SUMMARY edit-ok); test post `uat-16-2-blog-editor` (id 105) deleted by the user on 2026-10-28 | closed |
| T-16.2-27 | Elevation of Privilege | sudo on the server | low | accept | See AR-16.2-02 | closed |
| T-16.2-28 | Tampering | test isolation | low | mitigate | `sm-journal-plugin/vite.config.ts:38`; memblob, testcontainers; no ssh, curl or child_process in tests | closed |
| T-16.2-29 | Repudiation (false green) | phase gate | medium | mitigate | `summercms.go/scripts/check-phase16.2.sh:143-270` detectors, `:376-450` self-test, `:570-586` NOT RUN and `PHASE162_REQUIRE_POSTGRES` | closed |
| T-16.2-30 | Tampering (security regression) | strip, policy, permission tests | medium | mitigate | `check-phase16.2.sh:64-130` named tests, `:479-482` 16.1 policy tests pinned to `c60abc6`, `:502` corpus `cmp` | closed |
| T-16.2-36 | Spoofing / DoS | layer purge route | medium | mitigate | `server/api/__journal__/cache/purge.post.ts:25-30`; `app/utils/siteCache.ts:39` 32-char minimum, `:40` proxied → 404, `:43` timingSafeEqual over sha256; staging vhost `:236-239` `limit_except`; 07-SUMMARY purge-ok `200 83 403 401` | closed |
| T-16.2-37 | Information Disclosure | purge token | high | mitigate | log-capture tests `5eea57f:classes/site_cache_test.go:158-162`, now `modules/cooler/sitecache_test.go:257`; `DEPLOY.md:86,210-213` urandom/printf, `-H @-`; only `token-match` crossed ssh. See Follow-ups for a later leak path | closed |
| T-16.2-38 | Tampering (stale content) | purge ordering | medium | mitigate | lagoon.AfterCommit `5eea57f:classes/site_cache.go:181`, now `cooler/sitecache.go:306`; tests `site_cache_admin_test.go:162,240` | closed |
| T-16.2-39 | Denial of Service | admin save path | low | mitigate | 2 s WithTimeout(WithoutCancel) `cooler/sitecache.go:32,159`; tests `site_cache_admin_test.go:319,349` | closed |
| T-16.2-40 | Information Disclosure | purge URL misconfiguration | medium | mitigate | `cooler/sitecache.go:82-109` (was `site_cache.go:57-84`): both-or-neither, no userinfo or fragment, http only to loopback, 32-char token | closed |
| T-16.2-41 | Tampering (log injection) | layer purge log line | low | mitigate | `siteCache.ts:83` strips control chars, `:14` 200-char slug cap | closed |
| T-16.2-42 | Tampering (cache poisoning) | Nitro route cache | medium | mitigate | `nuxt.config.ts:77` `cache: false`; `purge.post.ts:18` no-store; `testing/purge-check.mjs:175-184` | closed |
| T-16.2-43 | Denial of Service | full route-cache purge | low | accept | See AR-16.2-03 | closed |
| T-16.2-44 | Repudiation (false-green gate) | phase gate | medium | mitigate | `check-phase16.2.sh:72,88,93,557,565`; VALIDATION 07-T3 green with `PHASE162_REQUIRE_POSTGRES=1` | closed |
| T-16.2-45 | Tampering / Repudiation | tags and pushes | high | mitigate | v0.1.20 (`ad7981f → aba063f`), layer v0.3.0 (`c09e0d0 → 25e9bfe`) match 07-SUMMARY; heads on origin; sha256 matched; site lockfile `3a920c7` resolves to `25e9bfe` | closed |
| T-16.2-46 | Tampering | UAT database and Winter site | medium | mitigate | 07-SUMMARY: read-only snapshots, human-only data changes, deploy-ok `.prev` rollback checks | closed |
| T-16.2-SC | Tampering | packages (all seven plans) | high | mitigate | 01, 03, 06: no dependency file changes; 02: exact `@tiptap/*` pins 3.31.4, committed lockfile, `scripts/check-dist.sh` rebuild diff, human "packages approved"; 04, 07: layer version bump and pin only, `node:crypto` only; 05: `build.sh:53` `--frozen-lockfile` | closed |
*Status: open · closed · open — below high threshold (non-blocking)*
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-16.2-01 | T-16.2-08 | Submitting the served value only skips the write; the skip cannot store anything Decode did not already accept (`field_type.go:539-541`) | plan 16.2-01 threat model; reconfirmed by audit | 2026-10-28 |
| AR-16.2-02 | T-16.2-27 | Server sudo is limited to `nginx -t`, nginx reload and supervisorctl (`DEPLOY.md:34`); the release used supervisorctl only | plan 16.2-05 threat model; reconfirmed by audit | 2026-10-28 |
| AR-16.2-03 | T-16.2-43 | Only authenticated loopback callers can purge; admin saves are rare; each page re-renders once from the local API | plan 16.2-07 threat model; reconfirmed by audit | 2026-10-28 |
*Accepted risks do not resurface in future audit runs.*
---
## Follow-ups (outside the 16.2 register)
- **Purge token likely exposed on UAT (later regression, not 16.2 code).** The site app pins vue-journal-layer v0.4.0, which brings `@nuxtjs/seo` / nuxt-site-config; that copies every `NUXT_SITE_*` variable into page payloads, and `sm-grzybyfunkcjonalne-app/DEPLOY.md:86` names the token `NUXT_SITE_CACHE_PURGE_TOKEN`. Fixed in the layer at v0.6.0 (`e0cc871`). Not checked on the live server. Action: bump the pin to ≥ v0.6.0, rename to `NUXT_CACHE_PURGE_TOKEN`, rotate the token.
- **Surface added after planning, no threat ID:** Blog Editor preview pane (empty-sandbox `srcdoc` iframe with CSP meta, `sm-journal-plugin/assets/src/blogeditor/preview.ts:6-14,190`, accepted in UAT test 5); admin "View site" link (`admin/src/components/shell/AppShell.vue:80-81`, `noopener noreferrer`); visitor X-Forwarded-For forwarded to Go on cached renders (`vue-journal-layer/server/plugins/journal-client-address.ts`, commit `03a90e9`).
- **Housekeeping:** WINDOWS entry 19 still needs the staging vhost install (frame-src); the test-post part is done. 16.2-VALIDATION rows 07-T4..T6 still read pending although 07-SUMMARY records them as passed.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-28 | 47 | 47 | 0 | msd-security-auditor (retroactive, State B); T-16.2-35 closed after the user deleted UAT post 105 |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-10-28

View File

@@ -0,0 +1,87 @@
---
phase: "19"
slug: "chat-and-chatchannels"
status: verified
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
threats_open: 0
asvs_level: 1
created: "2026-10-28"
---
# Phase 19 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
Implementation lives in the sibling repos `sm-chat-plugin` and `sm-chatchannels-plugin`; the user-namespace authorizer and `user.Deleting` event are in `sm-user-plugin` v0.1.2. Paths below are relative to the meta repo.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| client → /_chat/api/v1, /_chatchannels/api/v1 | untrusted JSON, ids and multipart files cross jwt.auth | message bodies, attachment bytes, conversation ids |
| ChatService write → Postgres | authenticated actor ids become conversation and message rows | user-authored content |
| ChatService → lighthouse / Centrifugo | realtime payloads cross to subscribers | event payloads (ids only) |
| Authorizer → subscribe | client-chosen channel string trusted only after participant check | channel names |
| download handler → blob store | private system_files bytes leave only after a participation check | private attachments |
| DM start → other users | unsolicited conversation creation | conversation rows |
| ChannelService → resolver | host-installed access policy; default deny-all | context access decisions |
| mention body → users.handle | untrusted @tokens become mention rows | user ids |
| user delete → chat/channel tables | GDPR cascade must not leave prefs/members or wipe others' rows | personal data |
| golden files / check-phase19.sh → CI | recorded PHP payloads and sibling paths become the acceptance oracle | test oracle |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19-01 | Information disclosure | api/download.go, message GET | high | mitigate | `sm-chat-plugin/api/download.go` collapses missing/unauthorized to `writeNotFound`; `AttachmentForDownload` checks `CanView`; `TestAttachmentDownload` (http_tracer_test.go:340) | closed |
| T-19-02 | Elevation of privilege | api/dm.go | medium | mitigate | `chat-dm` bucket Max 10 / minute (routes.go:69); `TestDMThrottle` in PG_CHAT_TESTS | closed |
| T-19-03 | Tampering | classes/attachment_policy.go | high | mitigate | `MaxAttachments = 5`, `MaxAttachmentBytes`, `BannedExts` svg/html/xml + banned MIME list; `TestAttachmentPolicy` | closed |
| T-19-04 | Information disclosure | lighthouse Bind payload | high | mitigate | ids-only broadcast payload; `TestChatTracer` (tracer_test.go:17) and golden_test.go fail if a publication contains `content` | closed |
| T-19-05 | Elevation of privilege | classes/authorizer.go | high | mitigate | parses `chat:conversation:{id}` (one optional presence: prefix), `Denied("not a participant")`, capabilities `["prs"]`, checked per subscribe | closed |
| T-19-06 | Elevation of privilege | classes/mention_parser.go | high | mitigate | `ResolveMentionedParticipantIDs` joins `golem15_chat_participants` for the conversation and excludes `senderID` (channel_service.go:1158) | closed |
| T-19-07 | Information disclosure | user.Deleting → SeverUserFromContext | high | mitigate | `FireDeletingEvent` fires inside the delete tx (sm-user-plugin controllers/users_admin_controller.go:525); chatchannels `onUserDeleting` → `SeverUserFromContext`; `TestDeletingCascade` | closed |
| T-19-08 | Denial of service | classes/search_indexer.go | medium | mitigate | index/deindex errors logged at Warn, never returned (fail-open); `TestSearchIndexer` | closed |
| T-19-09 | Elevation of privilege | api/attachments.go pending field | high | mitigate | `PendingField(actorID)` = `chat-pending-{actorID}`; claiming another actor's pending row refused (classes/attachments.go:22) | closed |
| T-19-10 | Elevation of privilege | user namespace authorizer | high | mitigate | sm-user-plugin `AuthorizeUser` allows only `user:{id}` for that user, rejects double presence/extra segments; `RegisterUserAuthorizer` skips if registered; `TestUserAuthorizer` in check-phase19.sh | closed |
| T-19-11 | Denial of service | mention regex | medium | mitigate | RE2 `@([A-Za-z0-9_.-]{2,255})`, bounded, no nested quantifiers (mention_parser.go:8) | closed |
| T-19-12 | Elevation of privilege | DenyAll + Boot Warn | medium | mitigate | `DenyAll` returns false for both methods; boot.go:64 Warns while resolver is deny-all | closed |
| T-19-13 | Information disclosure | TestGoldenEvents | high | mitigate | golden_test.go:241 fails when a publication payload contains key `content` | closed |
| T-19-14 | Elevation of privilege | mention resolve | high | mitigate | participant-only handle match (same query as T-19-06); `TestHandleMention` (sm-chatchannels-plugin http_tracer_test.go:37) | closed |
| T-19-15 | Elevation of privilege | classes/service.go canSend | high | mitigate | `canSend` returns `isParticipant` only (service.go:304); `ErrNotParticipant` mapped to 404 (api/errors.go:57); `TestGapClosureSmoke`, `TestTeammateDMWriteIDOR` | closed |
| T-19-16 | Tampering | user.Deleting → created_by CASCADE | high | mitigate | chat `onUserDeleting` → `ReassignCreatedBy` in the Deleting tx (boot.go:187, service.go:115); baseline CASCADE untouched | closed |
| T-19-17 | Elevation of privilege | TestTeammateDMWriteIDOR + PG_CHAT_TESTS | high | mitigate | listed in `PG_CHAT_TESTS` and in the self-test required tokens of scripts/check-phase19.sh | closed |
| T-19-18 | Tampering | TestDeletingCascade creator path | high | mitigate | test fires Deleting then `Unscoped` deletes Alice (http_tracer_test.go:301); `TestDeletingCascade` in `PG_CHATCHANNELS_TESTS` | closed |
| T-19-SC | Tampering | go.mod requires, scripts/check-phase19.sh | high | mitigate | plugin direct requires (gormigrate, pgx, testcontainers, gocloud.dev, gorm) are the framework's pinned versions plus Golem15 modules; gate installs no packages and `--self-test` plants failures | closed |
*Status: open · closed · open — below high threshold (non-blocking)*
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
No accepted risks.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-28 | 19 | 19 | 0 | /msd-secure-phase (orchestrator, ASVS L1 grep verification) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-10-28

View File

@@ -0,0 +1,124 @@
---
phase: "19.6"
slug: "two-factor-auth-and-device-flow-in-sm-user-plugin"
status: verified
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
threats_open: 0
asvs_level: 1
created: "2026-10-28"
---
# Phase 19.6 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
Implementation lives in sibling repos: **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **M** = `sm-mfa-plugin`, **S** = `sm-sociallogin-plugin`, **D** = `sm-demo-app/vue-demo-app`; **F** = this repo. The detailed per-threat evidence (mutation checks, log-call sweep, D-14 passkey rules, D-18 hardening) is in `19.6-SECURITY-REVIEW.md`; this file is the register and sign-off.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| first-factor login → token mint | a correct password must not yield a token while a second factor is pending | credentials, JWT |
| sm-user-plugin → other plugins | gates, admin reset service and listeners run M's code inside U's requests and transactions | user ids, transaction handle |
| unauthenticated device → devices/initiate, devices/status | anyone can create sessions and poll with a token | device token, short code, device name |
| signed-in user → devices/authorize, list, revoke | a bearer JWT binds or ends device sessions | device session ids, jti |
| JWT refresh → device revocation | a refreshed device token must stay revocable | jti lineage |
| holder of a password → challenge routes | unauthenticated clients present challenge tokens and guessed codes | challenge token, TOTP/email/recovery codes |
| signed-in user → management routes | a JWT changes the user's own factors | TOTP secrets, recovery codes, credentials |
| plugin → database / mailer | secrets at rest; one-time codes leave by email | hashed tokens, encrypted secret, bcrypt codes |
| browser authenticator / frontend origin → WebAuthn routes | attestations and assertions bound to server-side ceremony state, RP ID and origins | WebAuthn ceremony data |
| anonymous client → passwordless routes | a passkey alone yields a token | assertion, JWT |
| social provider callback → signIn | a social identity must not bypass an enabled second factor | provider identity, JWT |
| administrator → Users screen, settings | privileged reset of a user's factors; global 2FA policy | settings values |
| demo frontend → API | server-generated SVG rendered in the browser | QR SVG |
| test results → phase verdict; releases → app pins | a skipped test must not pass; fixes must reach every pin | gate output, git refs |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.6-01 | Elevation of privilege | login gate consultation | high | mitigate | U `controllers/login_gates.go` `CheckLoginGates`, called from `Login` before restore/mint; gate error → opaque 500; `TestLoginGates`, `TestLoginGateOrder`, `TestLoginGateSmoke/gate_error` | closed |
| T-19.6-02 | Tampering | soft-delete restore before second factor | medium | mitigate | restore after the gates; `TestLoginGateOrder/challenged_soft_deleted_stays_deleted` (U), `TestGate/deactivated_user_is_challenged_and_stays_deactivated` (M) | closed |
| T-19.6-03 | Spoofing | trusted devices surviving a password change | medium | mitigate | `AccessRevokedEvent` in the write transaction (U); M `classes/admin.go` `OnAccessRevoked`; `TestPasswordChangedSources`, `TestRevocation` | closed |
| T-19.6-04 | Elevation of privilege | Reset 2FA record action | medium | mitigate | U `classes/two_factor_admin.go` `PermissionResetTwoFactor` registered in `admin_permissions.go`, no default role; `TestResetTwoFactorAction`, `TestAdminReset` | closed |
| T-19.6-05 | Tampering | short-code guessing | medium | mitigate | U `classes/device_auth.go` `NewDeviceShortCode` (crypto/rand, 31^8); guarded authorize UPDATE; `TestDeviceCodes`, `TestDeviceAuthorize` (mutation-checked) | closed |
| T-19.6-06 | Spoofing | device-code phishing | medium | accept | D-12c, see AR-01 | closed |
| T-19.6-07 | Elevation of privilege | device hand-over race | high | mitigate | U `controllers/devices.go:282,300` guarded `status = 'confirmed'` UPDATE; `TestDeviceHandOverConcurrent` (mutation-checked) | closed |
| T-19.6-08 | Elevation of privilege | revoked device keeps refreshing | high | mitigate | U `classes/device_auth.go` `HandOverDeviceJTI`; revoke blacklists jti; `TestDeviceRevokeAfterRefresh`; `blacklist_grace` fork see AR-02 | closed |
| T-19.6-09 | Information disclosure | device tokens at rest and in URLs | medium | mitigate | SHA-256 only; F `modules/surf/accesslog.go` logs no path; `TestDeviceTokensAtRest`; proxy logging see AR-05 | closed |
| T-19.6-10 | Denial of service | initiate filling the table | low | mitigate | user-api bucket, bounded cleanup (≤500); `TestDeviceInitiate/cleanup_is_bounded` | closed |
| T-19.6-11 | Tampering | attacker-chosen device_name | low | mitigate | ≤255 runes, 422; `TestDeviceInitiate/device_name` | closed |
| T-19.6-12 | Tampering | core plugin contract | medium | mitigate | additive only; gate `--userplugin`, `--fonoteka` | closed |
| T-19.6-13 | Spoofing | TOTP/email brute force | high | mitigate | M `classes/challenge.go` `FailureCapReached`, 2fa-verify bucket, 5 attempts; `TestFailureCap`, `TestRoutesAndBuckets` | closed |
| T-19.6-14 | Tampering | parallel verifies | high | mitigate | M `classes/challenge.go:96,119` `completed_at IS NULL AND attempts < max_attempts`; `TestChallengeLifecycle/one_completion_when_factors_race` (mutation-checked) | closed |
| T-19.6-15 | Spoofing | TOTP replay | medium | mitigate | M `classes/methods.go` `AcceptTOTPCounter`; `TestTOTP/replay` | closed |
| T-19.6-16 | Spoofing | email downgrade of TOTP-only user | high | mitigate | M `classes/email.go` `SendEmailCode` gated on enabled email method; `TestEmailCode/gating` | closed |
| T-19.6-17 | Denial of service | mail bombing | medium | mitigate | M `classes/email.go:83` `email_sends < 3`; `TestEmailCode/sends_replace_and_cap` (mutation-checked) | closed |
| T-19.6-18 | Information disclosure | secrets at rest | high | mitigate | SHA-256 tokens, M `models/two_factor_method.go` `Secret lagoon.Encrypted`, bcrypt codes; `TestTokensAtRest` | closed |
| T-19.6-19 | Information disclosure | logs and error strings | medium | mitigate | log-call sweep (SECURITY-REVIEW "Log calls"); `TestEmailCode/never_logged` | closed |
| T-19.6-20 | Spoofing | stolen trusted-device token | medium | mitigate | M `classes/trusted.go`, `classes/gate.go`; `TestTrustedDevice`, `TestRevocation` | closed |
| T-19.6-21 | Elevation of privilege | management routes on other users' rows | high | mitigate | JWT-scoped queries, digit `{id}`; `TestRoutesAndBuckets`, `TestWebAuthn/delete_scoped_to_the_owner` | closed |
| T-19.6-22 | Information disclosure | recovery codes after creation | medium | mitigate | shown once, count route needs password; `TestRecovery/first_set_only_when_created`, `count_route` | closed |
| T-19.6-23 | Denial of service | bcrypt CPU per wrong code | low | mitigate | M `classes/recovery.go` `NormalizeRecoveryCode` (≤32 chars); `TestRecoveryCodeFormat` | closed |
| T-19.6-24 | Tampering | settings values | low | mitigate | M `models/settings.go` `BeforeValidate` allow-lists; defect fixed in M `71d8f27`, released v0.1.1; `TestSettings`, `TestPHPUser005` | closed |
| T-19.6-25 | Spoofing | client-chosen TOTP secret at confirm | low | accept | PHP parity, see AR-03 | closed |
| T-19.6-26 | Spoofing | client-supplied WebAuthn challenge | high | mitigate | server-side state in cooler, single-use `Pull`; `TestWebAuthnShapes/registration_state`, `TestWebAuthn/registration` | closed |
| T-19.6-27 | Elevation of privilege | passkey login with second-factor-only credential (D-14) | high | mitigate | M `classes/passwordless.go` `ValidatePasskeyLogin` with UV, `passwordless` column check; `TestPasswordless` (mutation-checked); D-14 table in SECURITY-REVIEW | closed |
| T-19.6-28 | Spoofing | cloned authenticator | medium | mitigate | `CloneWarning` refusal (M `classes/webauthn.go:506`, `classes/passwordless.go:110`); `TestWebAuthn/assertions` | closed |
| T-19.6-29 | Spoofing | RP ID / origin misconfiguration | medium | mitigate | M `classes/config.go` `loadWebAuthnConfig`; `TestWebAuthnShapes`, `TestPasswordless/refused_foreign_origin` | closed |
| T-19.6-30 | Elevation of privilege | social sign-in bypassing 2FA (D-09) | high | mitigate | S `classes/flow.go:343` `CheckLoginGates` with `LoginSourceSocial` before FinishLogin; `TestSocialGate`, `TestEveryLoginPathIsGated/social_sign_in` | closed |
| T-19.6-31 | Elevation of privilege | passkey login of banned/suspended/inactive account | high | mitigate | M `classes/passwordless.go:126` `RejectIfThrottled` + activation check; `TestPasswordless/refused_banned`, `refused_suspended`, `refused_not_activated` | closed |
| T-19.6-32 | Tampering | remote operations (plan 03 pushes) | medium | mitigate | process control: fetch + range check before every push, no force, no deploy (19.6-03-SUMMARY "Remote operations") | closed |
| T-19.6-33 | Tampering | demo rendering QR SVG | low | mitigate | D `app/lib/twoFactor.ts` `svgDataUrl` (img data URL, no `v-html`); `twoFactor.test.ts` | closed |
| T-19.6-34 | Information disclosure | user handle from numeric id (A4) | low | accept | see AR-04 | closed |
| T-19.6-35 | Denial of service | ceremony state in memory cooler, multi-instance (A11) | low | accept | README; fails closed; see AR-06 | closed |
| T-19.6-36 | Tampering | check-phase19.6.sh verdict | medium | mitigate | F `scripts/check-phase19.6.sh --self-test` (fail/skip/zero-match/missing-name detectors) | closed |
| T-19.6-37 | Tampering | post-release fixes not reaching apps | medium | mitigate | `71d8f27` is an ancestor of M tag v0.1.1; pinned by sm-demo-app `9166a1e`, sm-bm-app `fe4e292` | closed |
| T-19.6-38 | Elevation of privilege | token mint path bypassing 2FA | high | mitigate | M `login_paths_test.go` `TestEveryLoginPathIsGated` (every mint site listed); activate-by-code residual = F-1, see AR-07 | closed |
| T-19.6-SC | Tampering | Go module installs (plans 01–04) | high | mitigate | M `go.mod`: pquerna/otp v1.5.0, boombuler/barcode v1.1.0, go-webauthn v0.18.2 (approved, `.planning/notes/2026-10-21-mfa-otp-webauthn.md`); `go list -deps` of M has no `gopkg.in/yaml.v3`; fonoteka.go `go.mod`/`go.sum` have no go-webauthn | closed |
*Status: open · closed · open — below high threshold (non-blocking)*
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-01 | T-19.6-06 | Device-code phishing: D-12c keeps PHP's authorize-with-JWT rule; the device list shows name and IP and revoke signs the device out at once; the frontend must show device name and address before confirming. | user (D-12c, 19.6-CONTEXT) | 2026-10-26 |
| AR-02 | T-19.6-08 | `blacklist_grace` > 0 lets two concurrent refreshes fork; only one jti is followed, so revoke misses the other until it expires. Default grace is 0; a password change ends both. | plan 01 (Pitfall 15) | 2026-10-26 |
| AR-03 | T-19.6-25 | Client-chosen TOTP secret at confirm (PHP parity): own account only, base32 and 16–64 chars validated, a current code from the secret required. | plan 02 threat model | 2026-10-26 |
| AR-04 | T-19.6-34 | PHP-compatible 8-byte user handle reveals only the internal user id to the user's own authenticator; keeps imported passkeys mappable. | plan 03 threat model (A4) | 2026-10-26 |
| AR-05 | T-19.6-09 | Reverse proxies may log `devices/status/{token}`; the token lives five minutes and is single use (README). | plan 01 threat model | 2026-10-26 |
| AR-06 | T-19.6-35 | With the memory cooler a WebAuthn ceremony must stay on one instance; multi-instance apps use the Redis cooler (README); ceremonies fail closed otherwise. | plan 03 threat model (A11) | 2026-10-26 |
| AR-07 | T-19.6-38 (F-1) | activate-by-code mints a token without the second factor only when `require_activation: false` and the user enabled 2FA before activating; the mailed activation code stands in for the second factor once. Low impact, non-default config. Deferred; pinned by `TestEveryLoginPathIsGated/mint_sites`; todo `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md`. | user (plan 04 Task 5, option c) | 2026-10-26 |
*Accepted risks do not resurface in future audit runs.*
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-26 | 42 | 42 | 0 | plan 04 security review (`19.6-SECURITY-REVIEW.md`; mutation checks, `check-phase19.6.sh --all` with Docker) |
| 2026-10-28 | 42 | 42 | 0 | /msd-secure-phase (L1 grep verification of cited mitigations in U, M, S, D, F; short-circuit: register authored at plan time, ASVS 1) |
Threat count: T-19.6-01..38 plus four T-19.6-SC rows (plans 01–04), merged into one row above.
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-10-28

View File

@@ -0,0 +1,375 @@
---
phase: 32-seo-foundation
plan: 06
subsystem: release
tags: [release, apisurface, additive-gate, compat-gate, unit-tests, vitest, sm-seo-plugin, sm-journal-plugin, sm-demo-app, changelog]
requires:
- phase: 32-01
provides: cross-plugin admin form extensions (pact.AdminFormExtension, cabana compile and save)
- phase: 32-02
provides: record events (pact.RecordSaved/RecordDeleted, cabana.EmitRecord*), content descriptors (pact.ContentDescriptor, signpost lookups), Journal adoption
- phase: 32-03
provides: sm-seo-plugin core (entries, rules, settings, resolver, history)
- phase: 32-04
provides: redirects, 404 log, robots.txt
- phase: 32-05
provides: management API, MCP tools, batches, undo, CSV, check-phase32.sh, 32-05-demo-mount.patch
provides:
- "scripts/check-phase32.sh stages additive (scripts/apisurface API-surface comparison with the previous release) and compat (three consumers tested, every other sibling built), with self-tests"
- "Framework unit matrices for form extensions, record events and content descriptors"
- "sm-seo-plugin unit tests (classes) and 52 vitest element tests; sm-journal-plugin descriptor, journal-tag and translated-slug tests"
- "SummerCMS v0.5.1 (patch, strictly additive), sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0 (first release), all pushed"
- "sm-demo-app mounts golem15.seo v0.1.0 (pushed, not deployed)"
- "Changelog post https://summercms.io/changelog/summercms-0-5-1"
affects: [33, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app, vue-seo-layer]
actuals:
tokens: 51000
tasks: 5
commits: 4
plan_head_before: 49502e8ccb98e29a067961641dd08e2d39e42f27
plan_head_after: a9a7f58eedaf70edd3472b6aebe01b2db4cbb498
tech-stack:
added: []
patterns:
- "Release gate: the API surface of every changed framework package is compared with the previous release tag (tags containing HEAD skipped); removed or changed elements and methods added to existing interfaces are refused"
- "Compat gate reads sibling repositories through temporary-index snapshots or detached worktrees and builds them in a symlinked scratch sibling layout"
- "Release steps are logged as they complete (32-06-release-log.md) so a resumed run never re-tags, force-pushes or re-posts"
key-files:
created:
- scripts/apisurface/main.go
- modules/cabana/form_extensions_test.go
- modules/cabana/record_events_test.go
- modules/signpost/content_test.go
- .planning/phases/32-seo-foundation/32-06-release-log.md
- .planning/phases/32-seo-foundation/deferred-items.md
- ../sm-seo-plugin/classes/*_test.go (resolver, redirect_graph, normalize, robots_value, robots, templates, csv, owner, issues, helpers)
- ../sm-seo-plugin/tests/elements/{text,robots,serp,robotstxt,transfer}.test.ts, tests/elements/css.ts
- ../sm-journal-plugin/content_descriptors_test.go
- ../sm-demo-app/config/golem15.seo.yaml, seo_mount_test.go, seo_mcp_test.go
modified:
- scripts/check-phase32.sh
- .planning/phases/32-seo-foundation/32-VALIDATION.md
- ../sm-seo-plugin/go.mod, README.md, classes/redirect_graph.go, classes/issues.go, classes/robots.go, assets/src/elements/measure.ts, assets/dist/seo.js
- ../sm-journal-plugin/go.mod, README.md, menu_item_types_test.go
- ../sm-demo-app/go.mod, go.sum, go.work, .gitmodules, summer.yaml, plugins.gen.go, boot_test.go, DEPLOY.md, deploy/nginx/demo.summercms.io.conf, plugins/golem15/journal, plugins/golem15/seo
key-decisions:
- "Release per Jakub's go-ahead: SummerCMS v0.5.1, sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0, all pushed with their branches; sm-demo-app's three commits pushed; nothing deployed"
- "The additive gate compares with the newest tag that does not contain HEAD, so a run on the tagged release commit compares it with the previous release, not with itself"
- "The demo Journal bump replaces the other session's uncommitted journal pointer drift on the same path (b129902, an ancestor of v0.2.5) with the v0.2.5 pointer; the oauthserver drift is left untouched"
- "check-pins is proven on the committed demo tree (clean detached worktree), because the live tree carries another session's oauthserver drift"
- "The optional app Journal bumps and per-plugin changelog posts were not named and are deferred"
patterns-established:
- "Post-release gate runs pick the previous release as the additive baseline (git tag --no-contains HEAD)"
requirements-completed: [SC-5, SC-1, SC-2, SC-3, SC-4, Q-01, D-106, D-90, D-91, D-92, D-93, D-98, D-22, D-25, D-09]
coverage:
- id: D1
description: "Additive and compat gate stages: the v0.5.1 API surface keeps every v0.5.0 element (cabana +2, pact +87, signpost +8 lines, nothing removed or changed, no interface method added); consumers build and test unchanged"
requirement: "D-106"
verification:
- kind: other
ref: "bash scripts/check-phase32.sh --additive (release commit 15b0d0d vs v0.5.0); bash scripts/check-phase32.sh --self-test"
status: pass
- kind: other
ref: "bash scripts/check-phase32.sh --all (compat stage: 3 consumers tested, 20 siblings built)"
status: pass
human_judgment: false
- id: D2
description: "Framework unit matrices for form extensions, record events and content descriptors"
requirement: "SC-5"
verification:
- kind: unit
ref: "go test ./modules/cabana ./modules/signpost (form_extensions_test.go, record_events_test.go, content_test.go)"
status: pass
human_judgment: false
- id: D3
description: "sm-seo-plugin class unit tests and element tests; Journal descriptor, journal-tag and translated-slug tests"
requirement: "SC-5"
verification:
- kind: unit
ref: "go -C ../sm-seo-plugin test ./... (TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution, TestIssues)"
status: pass
- kind: unit
ref: "npm --prefix ../sm-seo-plugin run test (52 element tests)"
status: pass
- kind: unit
ref: "go -C ../sm-journal-plugin test ./... (content_descriptors_test.go, menu_item_types_test.go)"
status: pass
human_judgment: false
- id: D4
description: "Releases tagged and pushed: SummerCMS v0.5.1, sm-journal-plugin v0.2.5, sm-seo-plugin v0.1.0"
requirement: "Q-01"
verification:
- kind: other
ref: "git ls-remote --tags origin v0.5.1 / v0.2.5 / v0.1.0 (aa6e0a5, 16d9326, 9cf0447)"
status: pass
human_judgment: false
- id: D5
description: "sm-demo-app mounts golem15.seo v0.1.0 with tagged requires; SEO tab and MCP catalog proven"
requirement: "SC-4"
verification:
- kind: integration
ref: "go -C ../sm-demo-app test -count=1 -tags summercms_mcp -run 'TestSEOToolsCatalog|TestGoModRequiresTags|TestSEOTabMounted|TestBootAllPlugins' . (Docker)"
status: pass
- kind: other
ref: "scripts/check-pins.sh in a clean worktree of 9451b31: check-pins: ok (17 modules)"
status: pass
human_judgment: false
- id: D6
description: "Full phase gate green with the demo mounted and no skips"
requirement: "D-90"
verification:
- kind: other
ref: "bash scripts/check-phase32.sh --all (exit 0, Phase 32 gate passed)"
status: pass
human_judgment: false
- id: D7
description: "Changelog post for v0.5.1 on summercms.io reads well and says the right things to users"
verification:
- kind: other
ref: "https://summercms.io/changelog/summercms-0-5-1 answers 200; API shows published at the tag date"
status: pass
human_judgment: true
rationale: "Wording and emphasis of public release notes are an editorial call"
duration: 1h 25m
completed: 2026-10-27
status: complete
---
# Phase 32 Plan 06: Unit tests, additive release gate and the v0.5.1 release Summary
**The phase is proven and released. An API-surface gate shows the framework change is strictly additive against v0.5.0, and a compat gate builds every sibling consumer unchanged. Unit tests cover the framework seams, the SEO plugin (Go and elements) and Journal. SummerCMS v0.5.1, sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0 are tagged and pushed. sm-demo-app mounts golem15.seo v0.1.0 and is pushed, not deployed. The changelog post is at https://summercms.io/changelog/summercms-0-5-1.**
## Performance
- **Duration:** about 1h 25m in two executor contexts (Tasks 1 to 3, then Task 5 after the Task 4 checkpoint)
- **Started:** 2026-10-27T11:51Z (plan base 49502e8)
- **Completed:** 2026-10-27T13:20Z
- **Tasks:** 5 (4 executed, 1 decision checkpoint)
- **Files modified:** 6 in this repository, 23 in sm-seo-plugin, 4 in sm-journal-plugin, 14 in sm-demo-app
## Task 4 answer (Jakub)
- **Decision:** release. Versions confirmed: v0.5.1, v0.2.5 and v0.1.0.
- **Approved pushes:**
- framework master and v0.5.1 (master carried 64 commits, including other sessions' work; accepted)
- sm-journal-plugin main and v0.2.5
- sm-seo-plugin main (first push) and v0.1.0
- sm-demo-app master with three commits
- **Changelog:** a post for v0.5.1.
- **Not named:** the optional app Journal bumps. **Nothing deployed.**
- **Gate:** the full run with the demo mounted and no test skips. The fixture is readable after the chown.
## Accomplishments
- `check-phase32.sh` has two new stages:
- **additive:** `scripts/apisurface` compares the exported API with the previous release and refuses removed or changed elements and methods added to existing interfaces. The self-test plants each case.
- **compat:** sm-sitemap-plugin, sm-journal-plugin and sm-demo-app are tested; every other sibling that requires the framework is built.
- **Unit tests:**
- framework matrices for form extensions, record events and content descriptors
- sm-seo-plugin classes (resolver, redirect graph, normalisation, robots value and matcher, templates, CSV, owner, issues) and 52 vitest element tests
- Journal: descriptors, the journal-tag type and translated slugs
- **Releases:** v0.5.1, v0.2.5 and v0.1.0 are tagged and pushed. Both plugins require v0.5.1.
- **Demo:** sm-demo-app requires v0.5.1, bumps Journal to v0.2.5 and mounts golem15.seo v0.1.0 (the 32-05 patch, the submodule and go.work).
- **Changelog:** v0.5.1 is announced.
## Task Commits
**summercms.go**
1. **Task 1: additive and compat stages** - `2b300ec` (test)
2. **Task 2: framework unit matrices** - `8c961f5` (test)
3. **Task 3D: validation map** - `15b0d0d` (docs). This is the release commit, tagged v0.5.1.
4. **Task 5 fix: additive baseline after tagging** - `a9a7f58` (fix). Local only, after the tag.
**sm-seo-plugin**
- Task 3 fixes and refactors:
- `e957c00` refactor: run the redirect simulation over a rule list
- `79567b3` fix: a loop through the site's absolute URL lists each address once
- `6d54fc7` refactor: build the issue report from loaded data
- `9461c74` fix: the result preview truncates at the last word boundary
- Task 3A: `9e45ddc` test: unit tests for the SEO classes
- Task 3B: `dcfbdc0` test: element backstops
- Task 5: `b19cd06` chore: require SummerCMS v0.5.1. Tagged v0.1.0.
**sm-journal-plugin**
- Task 3C: `fea88f8` test: content descriptors, journal-tag type and translated slugs
- Task 5: `f5a0394` chore: require SummerCMS v0.5.1. Tagged v0.2.5.
**sm-demo-app** (Task 5)
- `11f64bf` chore: require SummerCMS v0.5.1 (go.mod, go.sum)
- `11d485f` chore: bump journal to v0.2.5 (go.mod, go.sum, plugins/golem15/journal)
- `9451b31` feat: mount golem15.seo v0.1.0 (13 paths, all from this plan)
**Plan metadata:** the commit that adds this SUMMARY (docs).
`commits: 4` is measured in this repository with `git rev-list --count 49502e8..HEAD` before this SUMMARY's commit. Sibling commits are listed above.
## Release steps (from 32-06-release-log.md)
- **Pre-checks:**
- framework: newest tag v0.5.0; master was 64 ahead of origin/master and 0 behind.
- admin/: only `admin/tests/form/FormView.test.ts` changed, so no dist rebuild was needed.
- **Step 1, framework:**
- `go vet` and `go test -short` green.
- Annotated tag v0.5.1: tag object aa6e0a5 on commit 15b0d0d.
- `git push origin master`: 223f87e..15b0d0d, fast-forward checked after `git fetch`.
- `git push origin v0.5.1`; `ls-remote` confirms it.
- **Step 2, sm-journal-plugin:**
- `go get summercms@v0.5.1` with `GOWORK=off` and `GOPRIVATE`.
- README dependency line updated to v0.5.1 (signpost is now a runtime dependency).
- Build, vet and `test -short` green.
- Commit f5a0394; tag v0.2.5 (tag object 16d9326).
- Pushed main 7fc07d8..f5a0394 and the tag.
- **Step 3, sm-seo-plugin:**
- The same require, plus the README dependency line "SummerCMS v0.5.1 modules".
- Build, vet and `test -short` green.
- Commit b19cd06; tag v0.1.0 (tag object 9cf0447).
- `git push -u origin main` (a new branch on the empty remote) and the tag.
- **Step 4, sm-demo-app:**
- Found at the start: ` M plugins/golem15/journal` and ` M plugins/golem15/oauthserver`.
- Commit 11f64bf: require v0.5.1.
- The journal submodule HEAD (b129902) is an ancestor of v0.2.5, so it was moved to v0.2.5. Commit 11d485f: only the v0.2.5 pointer was staged, and it replaces the foreign journal drift on that path.
- Mount:
- `git apply` the 32-05 patch
- `git submodule add -b main` sm-seo-plugin, checked out at v0.1.0
- go.work `use ./plugins/golem15/seo`
- `go get sm-seo-plugin@v0.1.0` and `go mod tidy` (GOWORK=off)
- `summer build` printed ✓ Built bin/demo
- Build (plain and summercms_mcp), vet and `test -short` green. With Docker, TestBootAllPlugins, TestGoModRequiresTags, TestSEOToolsCatalog and TestSEOTabMounted pass.
- Commit 9451b31.
- check-pins: the live tree fails only on the foreign oauthserver drift. A clean worktree of 9451b31 prints `check-pins: ok (17 modules)`, and `GOWORK=off go build ./...` builds from the published tags (plain and mcp).
- Pushed master 8fcdbdd..9451b31. Not deployed.
- **Step 5, gate:** `check-phase32.sh --all` passes (details under Gate output).
- **Step 6, changelog:**
- `changelog-post.py source v0.5.1`, then the notes (## Highlights, ## Added).
- `publish` returned post id 45: https://summercms.io/changelog/summercms-0-5-1. It is published at the tag date and the page answers 200.
- The script read the token itself; it was never printed.
- **Step 7, deferred items:** deferred-items.md is written.
## Gate output (step 5)
`bash scripts/check-phase32.sh --all` ran with Docker, the demo mounted, no `PHASE32_*` overrides and no test skips. It exited 0 with "Phase 32 gate passed".
- **Stages:** PASS for self-test, go, plugins (TestNoTideJournalFixtures included), consumers, admin, docs, marker, additive, compat and docker.
- **compat:** sm-sitemap-plugin, sm-journal-plugin and sm-demo-app are tested. fonoteka.go, sm-bm-app, sm-grzybyfunkcjonalne-app and every sibling plugin are built.
- **docker:** TestSEOTabMounted and TestSEOToolsCatalog pass in the demo.
- **No failure lines:** no FAIL, `refuse:`, NOT MOUNTED or additive refusal.
- **Additive baseline:** the additive line of that run compared v0.5.1 with itself, which led to fix a9a7f58. Re-run on the release commit, it compared against v0.5.0: cabana +2, pact +87, signpost +8 lines.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Behaviour-neutral refactors so TestRedirectGraph and TestIssues run under -short**
- **Found during:** Task 3A
- **Fix:** the redirect simulation runs over a rule list, and the issue report is built from loaded data.
- **Commits:** sm-seo-plugin e957c00, 6d54fc7
**2. [Rule 1 - Bug] A loop notice listed an address twice**
- **Found during:** Task 3A
- **Fix:** a loop through the site's absolute URL lists each address once.
- **Commit:** sm-seo-plugin 79567b3
**3. [Rule 1 - Bug] The result preview did not truncate per UI-SPEC E-1**
- **Found during:** Task 3B
- **Fix:** the preview truncates at the last word boundary plus " …", and the dist bundle was rebuilt.
- **Commit:** sm-seo-plugin 9461c74
**4. [Rule 3 - Blocking] Extra test support files**
- **Found during:** Task 3
- **Fix:** classes/helpers_test.go and tests/elements/css.ts were added, and gorm.io/driver/postgres became a direct require in sm-seo-plugin's go.mod.
- **Commits:** 9e45ddc, dcfbdc0
**5. [Rule 3 - Blocking] No layout in happy-dom**
- **Found during:** Task 3B
- **Fix:** the 320 and 360 px element rows check the stylesheet's layout contract.
- **Commit:** dcfbdc0
**6. [Rule 3 - Blocking] The compat stage has to read other repositories without touching their trees**
- **Found during:** Task 1
- **Fix:** it reads them through temporary-index snapshots or detached worktrees and builds in a symlinked scratch sibling layout. There is a new PHASE32_SKIP_TESTS knob.
- **Commit:** 2b300ec
**7. No separate RED commits**
- **Found during:** Tasks 2 and 3
- **Reason:** the green-at-every-commit rule in CLAUDE.md.
**8. Element tests: 52, against the plan's minimum of 20.**
**9. [Rule 1 - Bug] The additive stage compared the release with itself once HEAD was tagged**
- **Found during:** Task 5, step 5
- **Issue:** `last_tag` picked the newest tag, which was v0.5.1 on HEAD.
- **Fix:** `git tag --sort=-v:refname --no-contains HEAD`. The re-run against v0.5.0 passes, and so does `--self-test`.
- **Commit:** a9a7f58 (local; it is after the pushed tag and was not pushed)
**10. [Rule 3 - Blocking] The live demo tree fails check-pins**
- **Found during:** Task 5, step 4
- **Issue:** another session's uncommitted oauthserver drift (b6a9586 against v0.1.2, 8bbec72).
- **Fix:** none in the live tree; the drift was left alone. check-pins is proven on a clean detached worktree of the pushed commit.
**11. [Rule 2 - Docs] Journal README dependency line**
- **Found during:** Task 5, step 2
- **Issue:** the plan named only the sm-seo-plugin README.
- **Fix:** the Journal README now names SummerCMS v0.5.1, and signpost moved from "in tests" to a runtime dependency (CLAUDE.md documentation rule).
- **Commit:** f5a0394
**12. [Rule 3 - Blocking] The demo's TestSEO* tests skip under -short (they need Docker)**
- **Fix:** they were run without -short with Docker. Both pass, tagged and untagged.
**13. `go mod tidy` in the demo**
- **Why:** to make sm-seo-plugin a direct require.
- **Effect:** go.sum gained grobotstxt's test-dependency checksums and dropped the obsolete v0.5.0 and v0.2.3 lines.
- **Commit:** 9451b31
---
**Total deviations:** 13 (5 Rule 1, 1 Rule 2, 5 Rule 3, 2 notes). **Impact:** the fixes are bugs in plugin code or in the gate, plus test scaffolding. No scope creep and no API change.
## Issues Encountered
- **g15office comment not posted.** This executor has no g15office tool. The orchestrator posts the release comment on Phase 32's milestone task `8gldeain` (`.planning/G15OFFICE.md`).
- **Retracted dependency in the demo.** sm-demo-app pins `github.com/gabriel-vasile/mimetype v1.4.15`, which upstream has retracted. This was there before the plan; it is recorded in deferred-items.md.
## Deferred (deferred-items.md)
- Journal bumps in sm-grzybyfunkcjonalne-app (with `golem15.journal.tag_pages: false`) and sm-summercmsio-app. Not named in the go-ahead.
- Changelog posts of their own for sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0.
- Deploying the demo.
- The demo's oauthserver drift (another session's) and the retracted mimetype pin.
## Known Stubs
None.
## User Setup Required
None.
## Next Phase Readiness
- Phase 33 can build on the tagged v0.5.1 seams and on sm-seo-plugin v0.1.0.
- The demo is pushed but not deployed. Deploy per `../sm-demo-app/DEPLOY.md` when Jakub says so.
- Local commits after the release: a9a7f58 and this SUMMARY's commit. They go out with the next framework push.
## Self-Check: PASSED
- Files: scripts/apisurface/main.go, modules/cabana/form_extensions_test.go, modules/cabana/record_events_test.go, modules/signpost/content_test.go, 32-06-release-log.md and deferred-items.md exist.
- Commits: 2b300ec, 8c961f5, 15b0d0d and a9a7f58 are in this repository. e957c00, 79567b3, 6d54fc7, 9e45ddc, 9461c74, dcfbdc0 and b19cd06 are in sm-seo-plugin; fea88f8 and f5a0394 in sm-journal-plugin; 11f64bf, 11d485f and 9451b31 in sm-demo-app.
- Remote tags: v0.5.1 (aa6e0a5), v0.2.5 (16d9326) and v0.1.0 (9cf0447).
- Changelog URL answers 200.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-27*

View File

@@ -0,0 +1,29 @@
# Plan 32-06 release log
Resume record of Task 5 (one-way). A resumed run confirms each `step <n> done` line and skips it: no tag is created twice, no push is forced, the changelog is posted once.
## Task 4 answer (Jakub, 2026-10-27)
- **Decision:** release (full Task 5).
- **Versions:** framework `v0.5.1`, sm-journal-plugin `v0.2.5`, sm-seo-plugin `v0.1.0`.
- **Pushes approved:** framework master + v0.5.1 (master carries 64 commits incl. other sessions' work, accepted); sm-journal-plugin main + v0.2.5; sm-seo-plugin main (first push, `-u`) + v0.1.0; sm-demo-app master (three commits: framework pin, Journal bump, golem15.seo mount).
- **Changelog:** post v0.5.1 on summercms.io.
- **Not named:** optional Journal bumps in sm-grzybyfunkcjonalne-app and sm-summercmsio-app (deferred).
- **Nothing is deployed.**
- **Gate:** full `check-phase32.sh --all` with the demo mounted, no PHASE32_ALLOW_UNMOUNTED, no TestNoTideJournalFixtures skip (fixture readable after the chown).
## Pre-checks
- framework: `git fetch --tags origin`; newest tag v0.5.0; master is 64 ahead, 0 behind origin/master (fast-forward).
- admin/: only `admin/tests/form/FormView.test.ts` changed since v0.5.0; the embedded dist is unaffected, no rebuild.
## Steps
- step 1 done: framework `go vet ./...` + `go test -short ./...` green; annotated tag v0.5.1 (tag object aa6e0a5, commit 15b0d0d) created; `git push origin master` 223f87e..15b0d0d (fast-forward checked after `git fetch origin`); `git push origin v0.5.1` new tag; `git ls-remote --tags origin v0.5.1` prints aa6e0a5.
- step 2 done: sm-journal-plugin `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/summercms@v0.5.1` (v0.5.0 => v0.5.1; go.sum unchanged, the module's replace points at ../summercms.go), README dependency line v0.5.1 (signpost now a runtime dependency); `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green; commit f5a0394 `chore: require SummerCMS v0.5.1` (go.mod, README.md); annotated tag v0.2.5 (tag object 16d9326, commit f5a0394); `git push origin main` 7fc07d8..f5a0394 (fast-forward checked); `git push origin v0.2.5` new tag; ls-remote prints 16d9326.
- step 3 done: sm-seo-plugin `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/summercms@v0.5.1` (v0.5.0 => v0.5.1; go.sum unchanged, replace to ../summercms.go), README dependency line "SummerCMS v0.5.1 modules"; `GOWORK=off go build ./... && go vet ./... && go test -short ./...` green; commit b19cd06 `chore: require SummerCMS v0.5.1` (go.mod, README.md); annotated tag v0.1.0 (tag object 9cf0447, commit b19cd06); `git push -u origin main` new branch (first push, origin was empty); `git push origin v0.1.0` new tag; ls-remote prints main b19cd06 and v0.1.0 9cf0447.
- step 4 done: sm-demo-app (pre-existing drift ` M plugins/golem15/journal` (4ab94da recorded, b1299021 checked out) and ` M plugins/golem15/oauthserver` (8bbec72 recorded, b6a9586 checked out) found at start). Commit 11f64bf `chore: require SummerCMS v0.5.1` (go.mod, go.sum; build, mcp build, vet, test -short green). Journal submodule HEAD b129902 confirmed an ancestor of v0.2.5, checked out at v0.2.5 (f5a0394); commit 11d485f `chore: bump journal to v0.2.5` (go.mod, go.sum, plugins/golem15/journal: 4ab94da -> f5a0394; this pointer replaces the other session's journal drift on the same path, only the intended v0.2.5 pointer staged). Mount: `git apply 32-05-demo-mount.patch`, `git submodule add -b main git@git.golem15.com:golem15/sm-seo-plugin.git plugins/golem15/seo` checked out at v0.1.0 (b19cd06), `./plugins/golem15/seo` in go.work, `GOWORK=off go get sm-seo-plugin@v0.1.0` + `GOWORK=off go mod tidy` (seo a direct require), `go run ../summercms.go/cmd/summer build` (✓ Built bin/demo; plugins.gen.go as in the patch, main.go unchanged); `go build ./...`, `go build -tags summercms_mcp ./...`, `go vet ./...`, `go test -short ./...` green; Docker runs: TestBootAllPlugins, TestGoModRequiresTags, TestSEOToolsCatalog, TestSEOTabMounted PASS (tagged), TestBootAllPlugins, TestSEOTabMounted PASS (untagged); commit 9451b31 `feat: mount golem15.seo v0.1.0` (13 paths, this plan's only). `scripts/check-pins.sh` in the live tree fails only on the other session's oauthserver drift; in a detached worktree of 9451b31 with `git submodule update --init plugins/` it prints `check-pins: ok (17 modules)`, and `GOWORK=off go build ./...` (plain and summercms_mcp) builds from the published tags; scratch worktree removed. `git push origin master` 8fcdbdd..9451b31 (fast-forward checked). Not deployed. ` M plugins/golem15/oauthserver` left as found.
- step 6a: `scripts/changelog-post.py source v0.5.1` (from ../sm-summercmsio-app) printed the tag message and 64 commit subjects since v0.5.0; user-facing notes written to a scratch file (## Highlights, ## Added; no Changed/Fixed entries in this release).
- step 5 done: `bash scripts/check-phase32.sh --all` (no PHASE32_* overrides, Docker, demo mounted at 9451b31, no test skips) exit 0, "Phase 32 gate passed": PASS self-test, go, plugins (TestNoTideJournalFixtures included), consumers, admin, docs, marker, additive, compat (sm-sitemap-plugin, sm-journal-plugin, sm-demo-app tested; every other sibling built), docker (TestSEOTabMounted, TestSEOToolsCatalog in sm-demo-app). No FAIL, refuse:, NOT MOUNTED or additive refusal lines. The additive stage of that run compared v0.5.1 with itself (last_tag picked the tag on HEAD); fixed in a9a7f58 `fix(32-06): the additive stage compares with the previous release once HEAD is tagged` (last_tag uses `--no-contains HEAD`); `check-phase32.sh --additive` on the release commit then compared with v0.5.0: cabana +2, pact +87, signpost +8 lines, nothing removed or changed, no interface method added; `--self-test` passes. a9a7f58 is local (after the tag, not pushed).
- step 6 done: `scripts/changelog-post.py publish v0.5.1 <notes> --excerpt "Plugins can extend each other's admin forms, react to record writes and map URLs to records. Strictly additive."` posted id=45, https://summercms.io/changelog/summercms-0-5-1 (title "SummerCMS 0.5.1", published_at 2026-10-27T13:00:42Z, the tag's date). Token read by the script from ~/.config/summercms/journal-token; never printed or copied.
- step 7 done: deferred-items.md lists the unnamed app Journal bumps (sm-grzybyfunkcjonalne-app, sm-summercmsio-app), plugin release posts, the deploy, the demo's oauthserver drift and the retracted mimetype pin. Nothing deployed.

View File

@@ -0,0 +1,278 @@
---
phase: 32
review: 32-REVIEW.md
titles: json
findings:
- id: CR-01
severity: critical
disposition: fixed
title: "[CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)"
- id: CR-02
severity: critical
disposition: fixed
title: "[CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`"
- id: WR-01
severity: warning
disposition: fixed
title: "[WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)"
- id: WR-02
severity: warning
disposition: fixed
title: "[WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different"
- id: WR-03
severity: warning
disposition: skipped
title: "[WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save"
- id: WR-04
severity: warning
disposition: fixed
title: "[WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved"
- id: WR-05
severity: warning
disposition: fixed
title: "[WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event"
- id: WR-06
severity: warning
disposition: fixed
title: "[WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type"
- id: WR-07
severity: warning
disposition: fixed
title: "[WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable"
- id: IN-01
severity: info
disposition: open
title: "[IN-A01] Dead check in `BindWritableFields`"
- id: IN-02
severity: info
disposition: open
title: "[IN-A02] Scoped API tokens never see an extension that declares no Permissions, and their posted values are dropped silently"
- id: IN-03
severity: info
disposition: open
title: "[IN-A03] `resolveExtensionLocales` wraps a nil error"
- id: IN-04
severity: info
disposition: open
title: "[IN-A04] `tab: default` on an extension field is refused although `default` names the default tab elsewhere"
- id: IN-05
severity: info
disposition: open
title: "[IN-A05] Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match"
- id: IN-06
severity: info
disposition: open
title: "[IN-A06] The extension's client assets are served to every administrator of the host form"
- id: CR-03
severity: critical
disposition: fixed
title: "[CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime"
- id: WR-08
severity: warning
disposition: fixed
title: "[WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule"
- id: WR-09
severity: warning
disposition: fixed
title: "[WR-B02] `AddressChanged` writes redirects without validation or the loop simulation"
- id: WR-10
severity: warning
disposition: fixed
title: "[WR-B03] The save-time loop check races with concurrent writes"
- id: WR-11
severity: warning
disposition: fixed
title: "[WR-B04] Undo is not idempotent under concurrent requests"
- id: WR-12
severity: warning
disposition: fixed
title: "[WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths"
- id: WR-13
severity: warning
disposition: fixed
title: "[WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs"
- id: WR-14
severity: warning
disposition: fixed
title: "[WR-B07] Export with a large `page` panics the handler"
- id: WR-15
severity: warning
disposition: fixed
title: "[WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site"
- id: WR-16
severity: warning
disposition: fixed
title: "[WR-B09] Social image values bypass validation on every non-admin write path"
- id: WR-17
severity: warning
disposition: fixed
title: "[WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import"
- id: WR-18
severity: warning
disposition: fixed
title: "[WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale"
- id: WR-19
severity: warning
disposition: fixed
title: "[WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301"
- id: IN-07
severity: info
disposition: open
title: "[IN-B01] The `regexCache` / `globCache` maps are never evicted"
- id: IN-08
severity: info
disposition: open
title: "[IN-B02] CSV `unescapeCell` changes legitimate values that start with an apostrophe"
- id: IN-09
severity: info
disposition: open
title: "[IN-B03] The transfer token has no expiry or user binding, and the fallback key is per process"
- id: IN-10
severity: info
disposition: open
title: "[IN-B04] Uniqueness checks swallow query errors inside the save transaction"
- id: IN-11
severity: info
disposition: open
title: "[IN-B05] `RenderTemplate` trims separators that belong to the content"
- id: IN-12
severity: info
disposition: open
title: "[IN-B06] Body caps do not account for JSON escaping"
- id: IN-13
severity: info
disposition: open
title: "[IN-B07] `/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production"
- id: IN-14
severity: info
disposition: open
title: "[IN-B08] Self canonical and `url` are built from the decoded path"
- id: CR-04
severity: critical
disposition: fixed
title: "[CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared"
- id: WR-20
severity: warning
disposition: fixed
title: "[WR-C01] The social image preview accepts `/\\host/...`, so the admin can load an image from an attacker's host"
- id: WR-21
severity: warning
disposition: fixed
title: "[WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character"
- id: WR-22
severity: warning
disposition: fixed
title: "[WR-C03] The 404 \"Open/Resolved\" filter and the redirect \"Chains\" filter disagree with the runtime matcher"
- id: WR-23
severity: warning
disposition: fixed
title: "[WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422"
- id: WR-24
severity: warning
disposition: fixed
title: "[WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit"
- id: WR-25
severity: warning
disposition: fixed
title: "[WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests"
- id: IN-15
severity: info
disposition: open
title: "[IN-C01] The regex length refusal puts an untranslated English phrase into the Polish UI"
- id: IN-16
severity: info
disposition: open
title: "[IN-C02] A migration comment is stale"
- id: IN-17
severity: info
disposition: open
title: "[IN-C03] The SERP preview's template rendering is not single-pass like the Go renderer"
- id: IN-18
severity: info
disposition: open
title: "[IN-C04] `plural()` can throw on an unusual `ui-locale`"
- id: IN-19
severity: info
disposition: open
title: "[IN-C05] Two test names do not describe what they assert"
- id: IN-20
severity: info
disposition: open
title: "[IN-C06] The inner controls' accessible name is the machine field name"
- id: IN-21
severity: info
disposition: open
title: "[IN-C07] The README test instructions skip the vitest suite, and the test script hides an empty run"
- id: IN-22
severity: info
disposition: open
title: "[IN-C08] Three partials are maintained twice"
- id: IN-23
severity: info
disposition: open
title: "[IN-C09] The `disabled` branches in the widget elements are dead, and would be broken if reached"
open: 23
total: 52
recorded: 2026-10-27T14:47:23.755Z
---
# Phase 32: Code Review Disposition
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| CR-01 | critical | fixed | 32-REVIEW-FIX.md |
| CR-02 | critical | fixed | 32-REVIEW-FIX.md |
| WR-01 | warning | fixed | 32-REVIEW-FIX.md |
| WR-02 | warning | fixed | 32-REVIEW-FIX.md |
| WR-03 | warning | skipped | 32-REVIEW-FIX.md |
| WR-04 | warning | fixed | 32-REVIEW-FIX.md |
| WR-05 | warning | fixed | 32-REVIEW-FIX.md |
| WR-06 | warning | fixed | 32-REVIEW-FIX.md |
| WR-07 | warning | fixed | 32-REVIEW-FIX.md |
| IN-01 | info | open | - |
| IN-02 | info | open | - |
| IN-03 | info | open | - |
| IN-04 | info | open | - |
| IN-05 | info | open | - |
| IN-06 | info | open | - |
| CR-03 | critical | fixed | 32-REVIEW-FIX.md |
| WR-08 | warning | fixed | 32-REVIEW-FIX.md |
| WR-09 | warning | fixed | 32-REVIEW-FIX.md |
| WR-10 | warning | fixed | 32-REVIEW-FIX.md |
| WR-11 | warning | fixed | 32-REVIEW-FIX.md |
| WR-12 | warning | fixed | 32-REVIEW-FIX.md |
| WR-13 | warning | fixed | 32-REVIEW-FIX.md |
| WR-14 | warning | fixed | 32-REVIEW-FIX.md |
| WR-15 | warning | fixed | 32-REVIEW-FIX.md |
| WR-16 | warning | fixed | 32-REVIEW-FIX.md |
| WR-17 | warning | fixed | 32-REVIEW-FIX.md |
| WR-18 | warning | fixed | 32-REVIEW-FIX.md |
| WR-19 | warning | fixed | 32-REVIEW-FIX.md |
| IN-07 | info | open | - |
| IN-08 | info | open | - |
| IN-09 | info | open | - |
| IN-10 | info | open | - |
| IN-11 | info | open | - |
| IN-12 | info | open | - |
| IN-13 | info | open | - |
| IN-14 | info | open | - |
| CR-04 | critical | fixed | 32-REVIEW-FIX.md |
| WR-20 | warning | fixed | 32-REVIEW-FIX.md |
| WR-21 | warning | fixed | 32-REVIEW-FIX.md |
| WR-22 | warning | fixed | 32-REVIEW-FIX.md |
| WR-23 | warning | fixed | 32-REVIEW-FIX.md |
| WR-24 | warning | fixed | 32-REVIEW-FIX.md |
| WR-25 | warning | fixed | 32-REVIEW-FIX.md |
| IN-15 | info | open | - |
| IN-16 | info | open | - |
| IN-17 | info | open | - |
| IN-18 | info | open | - |
| IN-19 | info | open | - |
| IN-20 | info | open | - |
| IN-21 | info | open | - |
| IN-22 | info | open | - |
| IN-23 | info | open | - |
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.

View File

@@ -0,0 +1,496 @@
---
phase: 32-seo-foundation
fixed_at: 2026-10-27T14:47:00Z
review_path: /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32-seo-foundation/32-REVIEW.md
iteration: 1
findings_in_scope: 29
fixed: 28
skipped: 1
status: partial
---
# Phase 32: Code Review Fix Report
Two fixer lanes ran in parallel on disjoint repositories. CR-04 was split into a framework half (WidgetField.vue element ordering) and a plugin half (element action queue with dropped-action retry); both are fixed.
## Fixed Issues
### CR-01: [CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** summercms.go
**Commit:** 676ca53
**Files modified:** `modules/cabana/crud.go`, `modules/cabana/form_extensions.go`, `modules/cabana/form_extensions_postgres_test.go`, `docs/backend/admin-controllers.md`
**Applied fix:**
- `mergedRules` skips extension fields, so the owner's model never gets a rule for them.
- `liftExtensionValues` checks `required` on the lifted value, and only for visible extensions:
- On create, the value must be sent and not blank. Blank means null or whitespace; for a translatable field it means the default locale's text.
- On update, the value is checked only when it is sent.
- New Postgres test `TestFormExtensionRequired` covers both cases:
- An administrator who sees the extension gets a 422 when the value is missing, blank or null, a 201 when it is filled, and a 200 on a partial update.
- An administrator who does not see the extension can create and update freely.
- Docs bullet added.
### CR-02: [CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** summercms.go
**Commit:** 5471b66
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/form_extensions_postgres_test.go`, `docs/backend/admin-controllers.md`
**Applied fix:** `fieldUpload` answers 404 when the field belongs to an extension that `extensionVisible` refuses. New Postgres test `TestFormExtensionUpload`:
- A visible upload gets a 201 on both create (id 0) and update.
- An administrator without the extension's permission gets a 404 on both, and `Upload` never runs.
The docs now say the extension's "field type uploads" answer 404 too. I found no other route that resolves an extension field by name without a visibility check.
### CR-04: [CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed (framework half only; the sm-seo-plugin element changes belong to the other fixer)
**Repo:** summercms.go
**Commit:** d660cd0
**Files modified:**
- `admin/src/components/form/fields/WidgetField.vue`
- `admin/tests/form/WidgetField.test.ts`
- `docs/backend/partials-and-widgets.md`
- `modules/boardwalk/dist/index.html`
- `modules/boardwalk/dist/assets/index-*.js` and `editor-*.js` (renamed by the rebuild)
**Applied fix:**
- `element = created` now runs before `mountPoint.value.append(created)`.
- New vitest case (`an action from connectedCallback (CR-04)`) mounts attached to the document. It uses an element that dispatches `summer-action` with a payload from `connectedCallback`, then checks that the POST is sent with that payload, that `busy` is set and then cleared, and that `summer-result` is dispatched. I confirmed the case fails on the old ordering.
- Dist rebuilt and committed; `check-admin-dist.sh` passes.
- Docs note that an action dispatched from `connectedCallback` is taken, and that one dispatched while `busy` is set is dropped.
_Lane: sm-seo-plugin_
**Status:** fixed (plugin half; `WidgetField.vue` was not touched, as instructed)
**Commit:** 3ecbd1f
**Files:** `assets/src/elements/actions.ts` (new), `serp.ts`, `robotstester.ts`, `labels.ts`, `assets/dist/seo.js`, `tests/elements/host.ts` (new), `tests/elements/serp.test.ts`, `tests/elements/robotstester.test.ts` (new)
**Applied fix:** Both elements now share a new `ActionQueue`:
- **Drop detection:** after `summer-action` is dispatched, a missing `busy` attribute means the host dropped the action. `inflight` is cleared and the payload is retried once on the next task.
- **Second drop:** reported as a failure. The SERP preview shows `contextFailed`; the tester shows a new `testFailed` label (en/pl).
- **First request:** `{op:'context'}` is sent on a microtask after `connectedCallback`.
- **Disconnect:** clears the request in flight and any pending retry. On reconnect the context is requested again, or the duplicate check is re-armed.
**Tests:**
- `tests/elements/host.ts` is a fake of `WidgetField.onAction`. It sets `busy` synchronously, settles asynchronously, sets `state=error` on failure and removes `busy`. It supports both wiring orders (`append-first` is the v0.5.1 host), dropping N actions, and an unwired host.
- The SERP and tester suites cover:
- the append-first host;
- a dropped retry;
- a host that never takes the action;
- a queue behind `busy`;
- `state=error` unblocking the next request;
- a disconnect while a request is in flight.
- Against the old `serp.ts`, 6 of the 7 new SERP host tests fail.
### WR-01: [WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** summercms.go
**Commit:** 468af06
**Files modified:**
- `modules/cabana/relation.go`
- `modules/cabana/deferred.go`
- `modules/cabana/crud.go`
- `modules/cabana/http.go`
- `modules/cabana/relation_child.go`
- `modules/cabana/record_events_postgres_test.go`
- `modules/cabana/README.md`
- `docs/backend/admin-controllers.md`
**Applied fix:**
- New `RelationService.saveLinkedChild`: snapshot, mutate, `Save`, then `recordChanges` and `emitChildSaved`. It is used by:
- hasMany `linkRelated` and `unlinkRelated`;
- the deferred commit's attach of children created in the session (`applyRelationBinding`).
- `unlinkDeferred` fires `RecordDeleted` after it deletes a child the session created.
- `CRUDService` gains an unexported `reg *Registry` field, set in `crud()`, so the deferred commit's `RelationService` can name the child's controller. It also now carries `app`. The change is additive and unexported.
- New subtests `hasMany link and unlink` and `deferred children` in `TestRecordEvents`.
- README and docs list the new event sources.
### WR-02: [WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed (requires human verification of the chosen meaning)
**Repo:** summercms.go
**Commit:** 1583651
**Files modified:**
- `modules/cabana/record_events.go`
- `modules/cabana/record_events_test.go`
- `modules/cabana/relation_child.go`
- `modules/cabana/reorder.go`
- `modules/pact/record_events.go`
- `modules/pact/README.md`
- `docs/plugins/extending.md`
**Applied fix:**
- New `eventLocale(ctx)` returns `primaryLanguage(towel.Locale)`. Admin saves, child writes and reorder now all report it, so a raw header like `pl-PL,pl;q=0.9` becomes `pl-PL`, and reorder no longer reports a different, defaulted code.
- The pact doc now defines `Locale` as the primary tag of the request locale. It states that an admin save writes the row in the default content locale and that per-locale work should key on `RecordChange.Locale`.
- New unit test `TestRecordEventLocale`.
I deliberately did not take the reviewer's alternative of switching `Locale` to the default content locale: that changes a field's meaning inside a patch release. No consumer reads `ev.Locale` today; I checked sm-seo-plugin, sm-sitemap-plugin and sm-journal-plugin. A human should confirm that "request locale, normalised" is the meaning we want.
### WR-04: [WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** sm-journal-plugin
**Commit:** 62c75b1
**Files modified:** `controllers/api/posts.go`, `controllers/api/record_events.go`, `record_events_test.go`, `README.md`
**Applied fix:**
- `Store` and `Update` run `Create`/`Save`, `applyPostAssocs` and `loadPostAssocs` in one `lagoon.Transaction`, with `cabana.EmitRecordSaved(ctx, app, tx, ...)` inside it. The event now follows the commit, and a failure rolls back the row.
- The site-cache purge still runs after the commit.
- New test step: a PUT with a new slug and an invalid `tags` value returns 500, fires no event and leaves the slug unchanged. It fails without the fix: the slug was `api-lost`.
- README updated.
### WR-05: [WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** sm-journal-plugin
**Commit:** 7a03640
**Files modified:** `controllers/api/record_events.go`, `record_events_test.go`
**Applied fix:**
- `submittedTranslations` lists `content_html` whenever a translation sends a non-blank `content`. This mirrors `classes.FormatTranslation`, so the snapshot and the event cover the same fields whether or not the body has been mutated yet.
- New test step: updating a pl `content` reports the pl `content_html` change with the old HTML as `Old`. It failed before the fix, with `Old` nil.
### WR-06: [WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** sm-journal-plugin
**Commit:** a6fc7cd
**Files modified:** `controllers/api/posts.go`, `controllers/api/posts_helpers.go`, `controllers/api/record_events.go`, `record_events_test.go`, `README.md`
**Applied fix:**
- `resolveTagIDs` and `applyPostAssocs` now return the tags created by name. These are unexported signature changes.
- The handlers emit `tagSavedEvent` (`Created`, with name and slug changes) for each new tag inside the post write's transaction.
- New test step: a PUT with a new tag name fires two events, the post and a `journal-tag` `Created`; reusing that tag fires only the post event.
- README updated.
### WR-07: [WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable
_Lane: summercms.go / sm-journal-plugin_
**Status:** fixed
**Repo:** summercms.go
**Commit:** 0e187cf
**Files modified:** `modules/cabana/form_extensions.go`, `modules/cabana/form_extensions_smoke_test.go`, `modules/pact/capabilities.go`, `docs/backend/admin-controllers.md`
**Applied fix:**
- `dropHiddenFields` now receives the owner's original field list and works on copies of the maps. It drops:
- the relation whose `fieldName` is hidden. This closes the panel, link, unlink, child CRUD and deferred-commit routes, because `relationOf` and `commitDeferred` read `cc.Relations`.
- a hidden partial field's path, from `formPartials` and `partials`. A path that is still used by the list header partial, the preview header partial or another owner partial field keeps rendering.
- New smoke test `TestFormExtensionHideEndpoints` checks that `relationOf` refuses the hidden relation-manager relation and that the hidden partial answers 404. With no `Hide`, both still work. It fails without the fix.
- The pact `Hide` doc and the docs updated.
### CR-03: [CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 2d81c26
**Files:** `updates/202610280001_add_golem15_seo_redirects_source_key.go` (new migration), `classes/redirects.go`, `redirects_test.go`
**Applied fix:** The new migration `202610280001_add_golem15_seo_redirects_source_key` adds `source_key`, a stored generated column. It is the SQL form of `exactKey`: the path lowercased, one trailing slash dropped (the root stays `/`), and the query kept as written. A partial index covers it `WHERE match = 'exact'`. `LoadRedirectSet` now fetches with `source_key IN keys`. Postgres computes the column, so every write path fills it (admin, API, batch, import, undo, address changes) and no released migration was edited. The column is not in the GORM model.
**Test:** the DB-backed `TestExactRedirectKeys`:
- `/old-page/` fires for both `/old-page` and `/old-page/`.
- `/list?Page=2` fires for its own query and not for `/list?page=2`.
- An ignore-case `/Mixed/Case/` fires for `/mixed/case`.
- The generated keys are asserted for each source, including `/` and `/A/?Q=1`.
With the old `lower(source)` fetch the test fails.
### WR-08: [WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule
_Lane: sm-seo-plugin_
**Status:** fixed (requires human verification: matching-order logic)
**Commit:** 58b2504
**Files:** `classes/redirects.go`, `classes/redirect_graph_test.go`, `README.md`
**Applied fix:** `exactKeys` now returns `path?query` before `path`, so the more specific exact rule wins. A unit test covers both insertion orders, and the README states the precedence.
### WR-09: [WR-B02] `AddressChanged` writes redirects without validation or the loop simulation
_Lane: sm-seo-plugin_
**Status:** fixed (requires human verification: redirect-graph logic)
**Commit:** cb614cb
**Files:** `classes/addresses.go`, `redirects_test.go`, `README.md`
**Applied fix:**
- **Address form:** absolute addresses on the site URL become paths, and the candidate `old → new` must pass `Problems`. A new address that is not a path on the site creates no redirect and logs a warning.
- **Shadowing rules:** step 1 now deletes every exact rule that `exactMatches` the new address with its case and slash options, not only an exact literal match.
- **Loop check:** steps 2 to 4 (create or update, then re-point) are simulated in memory with `checkRedirectRules` before anything is written. On a loop, no redirect is written, a warning is logged, and only the entry URL moves.
- **Entry URL:** still stored as the descriptor wrote it, so later address diffs keep working.
**Test:** the DB test `TestAddressChangeGuards` covers:
- the pattern-loop case;
- a case or slash variant that is removed;
- an absolute old address;
- a `//evil` new address.
### WR-10: [WR-B03] The save-time loop check races with concurrent writes
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** c2f136a
**Files:** `classes/redirect_graph.go`, `classes/addresses.go`, `classes/batch.go`, `redirects_test.go`, `README.md`
**Applied fix:** `lockRedirectWrites` runs `SELECT pg_advisory_xact_lock(...)` and is taken in three places:
- in `CheckRedirect` before it reads the rules (this covers `SaveRedirect`, the admin extension, undo and the 404 actions);
- in `applyAddressRedirects`;
- once at the start of every batch, which also covers imports.
**Test:** `TestConcurrentRedirectWrites` holds A → B uncommitted and checks that a concurrent B → A waits, then is refused as a loop. With the lock removed the test fails.
### WR-11: [WR-B04] Undo is not idempotent under concurrent requests
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** d20f14f
**Files:** `classes/undo.go`, `redirects_test.go`
**Applied fix:**
- `Undo` loads the revision `FOR UPDATE`.
- `UndoBatch` locks `batch_id = ? AND undone_at IS NULL` rows `FOR UPDATE`.
- The final mark is `WHERE id = ? AND undone_at IS NULL`. When `RowsAffected == 0` it returns `UndoRefusal{already_undone}`.
**Test:** `TestConcurrentUndo` checks that the second concurrent undo gets `already_undone`, the redirect is re-created once, and there is one undo history row. Before the fix the second undo hit a unique violation.
### WR-12: [WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 98fbeb6
**Files:** `classes/resolver.go`, `classes/notfound.go`, `redirects_test.go`
**Applied fix:**
- **Resolve:** `parseTarget` answers `ErrBadURL` (422) when the decoded path, the raw query or a decoded query key or value is invalid UTF-8 or holds NUL. `safeText` is the new helper for this check.
- **404 report:** `NotFoundPath` applies the same check to the logged address. The query stays escaped there, so it is safe text.
- **Referer:** cleaned of NUL and invalid UTF-8.
**Test:** in `TestNotFoundLog`, `/%00`, `/%FF` and query variants get 422 from resolve. The report refuses the path cases with 422 and never answers 5xx.
### WR-13: [WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** c2ba33e
**Files:** `classes/redirects.go`, `classes/redirect_graph_test.go`, `README.md`
**Applied fix:** A mutex-guarded map replaces the global `sync.Map`:
- it is keyed by the sorted rule IDs of the cycle, not the request URL;
- each cycle is logged again at most once an hour;
- the map is reset when it reaches 1024 keys.
**Test:** `TestCycleLogBounded` sends 5000 URLs through one cycle, which leaves one key, and checks that the map never exceeds the cap.
### WR-14: [WR-B07] Export with a large `page` panics the handler
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 59c37a8
**Files:** `classes/transfer.go`, `controllers/api/auth.go`, `transfer_test.go`
**Applied fix:**
- **Export:** a page past `Pages` is empty. The page is compared before multiplying, so an empty last page keeps the old semantics.
- **List endpoints:** `listParams` caps `page` at `maxPage = 1_000_000`.
**Test:** `page=9223372036854777` on export and `page=MaxInt64` on a list both answer 200 with no rows.
### WR-15: [WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site
_Lane: sm-seo-plugin_
**Status:** fixed (documentation only)
**Commit:** cab1b9a
**Files:** `README.md`, `routes.go` (comment)
**Applied fix:** The README has a new paragraph, "Behind the frontend server". It says the SSR host must be in `http.trusted_proxies` and must forward `X-Forwarded-For`. Without that, `seo-resolve` and `seo-notfound` become caps for the whole site, 600 requests a minute each. The `Buckets` doc comment says the same.
**Not done (needs a decision):** an authenticated resolve call that skips the throttle or gets a higher limit. It needs a new token or config key and a framework way for a surf `Bucket` to skip or raise its limit per request; today `FixedWindowLimiter` has no such hook. This is a design and framework change, not a plugin patch.
### WR-16: [WR-B09] Social image values bypass validation on every non-admin write path
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** c8e0961
**Files:** `classes/owner.go`, `classes/rules.go`, `classes/settings_write.go`, `mcp_test.go`
**Applied fix:** The following now refuse a non-empty value that is not a path or an http(s) URL, with `golem15.seo::lang.image.url_invalid`:
- `SetURLSEO` (`og_image`), which covers the API, MCP, batch `set_seo` and the records import;
- `SaveRule` (`og_image`);
- `SaveSettings` (`default_og_image`, only when that column is sent).
**Test:** `javascript:`, `data:`, `//` and a value with spaces get 422 on all three MCP routes.
### WR-17: [WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 24e6f38
**Files:** `classes/batch.go`, `classes/transfer.go`, `controllers/api/redirects.go`, `mcp_test.go`, `batch_test.go`, `transfer_test.go`
**Applied fix:** The new `RedirectFields.Problems()` refuses values outside {301, 302, 410}, {exact, pattern, regex} and {ignore, pass} with `api.value_invalid`. An empty match or query still means the default. It is checked before `Apply` in:
- API create and update (422 `validation_failed`);
- batch create and update redirect;
- the CSV redirects import (the row is refused with `transfer.row_invalid`).
**Tests:** MCP `status: 308`, `match: Regex` and `query: keep` get 422; a batch with 307 gets 422; an import with `glob` or `keep` gives 2 refused rows.
### WR-18: [WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** cff2d29
**Files:** `classes/owner.go`, `controllers/api/seo.go` (jsonschema text), `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `mcp_test.go`
**Applied fix:** When a locale other than the matched one is given, `SetURLSEO` requires it to be one of the locales the record's descriptor describes (existing `recordLocales`). Otherwise it refuses with the new `api.locale_unknown` (en/pl). This covers the API, MCP, batch and the records import.
**Test:** `xx` and `EN ` get 422 on `locale`; `pl` is written.
### WR-19: [WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301
_Lane: sm-seo-plugin_
**Status:** fixed (requires human verification: address-change logic in the job)
**Commit:** d8aa989
**Files:** `classes/generate.go`, `seo_tab_test.go`, `README.md`
**Applied fix:** `refreshEntry` (published records only) behaves as follows:
- an empty URL is filled;
- a URL that differs goes through `AddressChanged` (via auto), which creates the 301 and moves the URL;
- it never overwrites the URL silently.
**Test:** `TestGenerateEntries` covers a slug changed by a direct DB write: the job now creates `/pages/one → /pages/one-renamed` and `/pl/pages/one → /pl/pages/one-renamed`. A third run adds nothing.
### WR-20: [WR-C01] The social image preview accepts `/\host/...`, so the admin can load an image from an attacker's host
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 882d4ee
**Files:** `assets/src/elements/image.ts`, `assets/dist/seo.js`, `classes/field_types.go`, `classes/urlref_test.go` (new), `tests/elements/image.test.ts` (new)
**Applied fix:** `safeImageURL` (`/^\/(?![\/\\])/`) and `ValidURLRef` both refuse a path that starts with `/\`. `ValidURLRef` is also the canonical validator, so canonicals are stricter in the same way.
**Tests:** a Go table test and a TS test.
### WR-21: [WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** ed2d919
**Files:** `classes/csv.go`, `classes/csv_test.go`, `README.md`
**Applied fix:**
- A new `escapable(s)` helper checks for a formula character after any leading apostrophes.
- `escapeCell` prefixes one more apostrophe to such values.
- `unescapeCell` strips exactly one apostrophe, so it is the inverse of `escapeCell`.
- The round-trip rows now include `'=x`, `''-)` and `'plain`.
- The unit expectation for `unescapeCell("''=x")` changed to `'=x`.
This also resolves IN-08 (IN-B02), which is out of scope.
### WR-22: [WR-C03] The 404 "Open/Resolved" filter and the redirect "Chains" filter disagree with the runtime matcher
_Lane: sm-seo-plugin_
**Status:** fixed (requires human verification: SQL matcher logic). Pattern and regex rules remain a documented gap.
**Commits:** 7677d8d, ad419e9 (gofmt follow-up)
**Files:** `models/redirect.go`, `models/notfound.go`, `controllers/api/notfound.go` (jsonschema), `redirects_test.go`
**Applied fix:** A new `coversSQL(alias, addr)` is the SQL form of `exactMatches`:
- the source path is compared with its `ignore_case` and `trailing_slash` options, and the query exactly when the source has one;
- the indexed `source_key` from CR-03 serves as a prefilter.
`coveredSQL` (Open/Resolved, also MCP `list_not_found?status=`) and `chainSQL` (a path target that another rule's source names literally or an exact rule matches) both use it. The MCP `status` schema text now says that an address only a pattern or regex rule answers stays open in the filter but has `resolved: true` in its row.
**Test:** `TestCoverAndChainFilters` covers:
- `/missing/`, `/missing?utm=x`, `/case/` and `/q?id=1` are resolved;
- `/MISSING`, `/other` and `/q?id=2` are open;
- each row's badge agrees with the filter;
- `/c1 → /c2/` is a chain next to `/c2`.
### WR-23: [WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 4ca26e9
**Files:** `models/rule.go`, `mcp_test.go`
**Applied fix:** `models.MaxRuleURL = 2048`, and `Rule.Problems` refuses longer addresses with `rules.url_invalid`. The check covers the admin form, the API, MCP, batches and imports.
**Test:** a 3001-byte rule URL gets 422 on `url`.
### WR-24: [WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit
_Lane: sm-seo-plugin_
**Status:** fixed
**Commit:** 923d0f9
**Files:** `assets/src/elements/robots.ts`, `labels.ts`, `styles.css`, `assets/dist/seo.js`, `assets/dist/seo.css`, `tests/elements/robots.test.ts`, `classes/robots_value_test.go`
**Applied fix:**
- **Dates:** the new `validDate` checks a real calendar date and handles leap years. `2027-02-30` is refused, as Go's `time.Parse` refuses it.
- **Numbers:** an optional sign is accepted like `strconv.Atoi`, so `+5` reads as 5. The Go side was left unchanged so existing API callers keep working.
- **Unparseable stored value:** the element keeps the raw value and shows a note (`robotsUnparsed`, en/pl) in place of the Inherit display. All controls are disabled, `commit` is blocked, and an explicit Reset button emits `''`.
**Tests:** the Feb-30, Apr-31 and day-00 dates are in the TS malformed list. A leap day and `+5` are accepted. An element test covers keep, disable and Reset. A Go test checks that `+5` normalises to `max-snippet:5`.
### WR-25: [WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests
_Lane: sm-seo-plugin_
**Status:** fixed
**Commits:** 3ecbd1f (fake host, SERP host-protocol tests, `robotstester.test.ts`), 882d4ee (`image.test.ts` `safeImageURL`), 5c2ad6a (transfer host tests, image element tests, README test gate)
**Files:** `tests/elements/host.ts`, `serp.test.ts`, `robotstester.test.ts`, `transfer.test.ts`, `image.test.ts`, `README.md`
**Applied fix:**
- The fake widget host drives serp, robotstester and transfer. The tests cover queueing behind `busy`, `state=error` unblocking, the append-first ordering and dropped actions.
- `golem15-seo-image` is tested for safe and unsafe previews, upload id matching (a foreign id is ignored), the error and the `imageFailed` fallback, Remove, and the disabled state.
- The README test gate now includes `npm test`.
None.
- **No schema edits to released migrations:** CR-03 adds the new migration `202610280001_add_golem15_seo_redirects_source_key`. v0.1.0's migrations are untouched.
- **Framework half of CR-04:** the `WidgetField.vue` change belongs to the other fixer. The plugin now works with either wiring order.
- **Needs human verification of logic:** WR-08, WR-09, WR-19 and WR-22 change matching, redirect-graph or SQL semantics. Syntax checks and DB tests pass, but the logic should be reviewed by a person.
- **Behaviour now stricter for callers:**
- unknown redirect status, match or query values are refused (WR-17);
- unknown locales are refused (WR-18);
- invalid social images are refused (WR-16);
- `/\` paths are refused as image or canonical URLs (WR-20);
- rule URLs over 2048 bytes are refused (WR-23).
These are worth a changelog line when the plugin is tagged.
- **README sections updated:** redirects precedence, address changes, the loop lock, the runtime cycle log, "Behind the frontend server", entry generation, CSV escaping, and the Testing gate.
---
_Fixed: 2026-10-27T14:45:18Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_
## Skipped Issues
### WR-03: [WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save
_Lane: summercms.go / sm-journal-plugin_
**Status:** skipped
**File:** `modules/cabana/crud.go:980`, `modules/cabana/form_extensions.go` (`hydrateFormExtensions`)
**Reason:** Both suggested fixes have problems that need a design decision:
- **Log and omit the extension's keys.** This risks data loss. The admin SPA's `editablePayload` always sends translatable fields (`localeRecord(undefined)`), so a response without them makes the next save post empty locale maps to the extension's `Save`.
- **Run hydration after the commit.** This stops the rollback, but the request still answers 500 after a committed write. Show still fails for the same `Load`, so editing stays blocked either way.
A proper fix needs an "extension unavailable" state in the schema/record (for example, the failing extension's fields marked read-only or failed, so the SPA does not post them). That is beyond a v0.5.2 patch.
**Original issue:** `hydrateFormExtensions` runs inside the save transaction, so one plugin's read-side failure turns a successful save into a 500 and rolls it back. `ShowRecord` likewise fails the whole record.
---
_Fixed: 2026-10-27T15:55:00Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_

View File

@@ -0,0 +1,719 @@
---
phase: 32-seo-foundation
reviewed: 2026-10-27T13:31:23Z
depth: standard
files_reviewed: 221
lanes: [framework-and-journal (A, 50 files), seo-plugin-core-go (B, 68 files), seo-plugin-rest (C, 103 files)]
findings:
critical: 4
warning: 25
info: 23
total: 52
status: issues_found
---
# Phase 32: Code Review Report
The scope (221 files across summercms.go, sm-journal-plugin and sm-seo-plugin) was split into three parallel reviewer lanes at standard depth; each lane's report follows verbatim, with finding IDs prefixed A-, B- and C-. Per-lane file lists are in each section's header block.
**Critical:** CR-01 (CR-A01) (extension `required:` field blocks every host save), CR-02 (CR-A02) (field-type upload ignores extension Permissions/Visible), CR-03 (CR-B01) (exact redirects with trailing slash or upper-case query never match at runtime), CR-04 (CR-C01) (widget host assigns `element` after `append`, so actions sent from `connectedCallback` are dropped: SERP preview and robots tester never load; orchestrator confirmed in `admin/src/components/form/fields/WidgetField.vue:161-162`).
---
## Lane A: framework and Journal
```yaml
phase: 32-seo-foundation
reviewed: 2026-10-27T13:28:44Z
depth: standard
files_reviewed: 50
findings:
critical: 2
warning: 7
info: 6
total: 15
status: issues_found
```
### Phase 32: Code Review Report (scope A: framework seams + Journal adoption)
**Reviewed:** 2026-10-27T13:28:44Z
**Depth:** standard
**Files Reviewed:** 50
**Status:** issues_found
### Summary
I reviewed the D-91 form extensions (`cabana/form_extensions.go` and its hooks in crud, http, actions, partial_render, field_ml and field_type), the D-92 record events (`cabana/record_events.go`, `pact/record_events.go` and the emit sites in crud, reorder and relation_child), the D-93 content descriptors (`pact/content.go`, `signpost/content.go`), the apisurface/check-phase32 release gate, and the Journal adoption.
Parts that hold up:
- Transaction boundaries are correct. Every emit site runs inside `lagoon.Transaction`, through `CRUDService.transaction`, `RelationService.transaction` or the `reorderPost` transaction. So events are buffered until the commit and dropped on a rollback, including when a later step such as `projectFullRecord` or hydrate fails. After-commit listeners receive the outer ctx, not the tx ctx.
- Extension values are lifted out of the body before any owner path reads it.
- Invisible extensions are filtered consistently in the schema, show, save, widget-action and partial routes.
Problems found:
- **Required extension fields (CR-01 (CR-A01)).** A `required: true` field in an extension is turned into a model validation rule on the owner's record. That rule can never pass, so every save of the host form fails.
- **Upload route (CR-02 (CR-A02)).** The plugin field-type upload route does not check extension visibility. Administrators who are not allowed to see an extension can still call its `Upload`.
- **Record-event coverage and locale (warnings).** Some admin writes fire no event: hasMany link/unlink and the delete of a deferred child. `RecordSaved.Locale` is not a content locale.
- **Journal MCP writes (warnings).** These can commit a slug change and fire no event, and they report a wrong `Old` value for derived `content_html` translations.
### Narrative Findings (AI reviewer)
### Critical Issues
### CR-01: [CR-A01] `required: true` on a form extension field makes every save of the host form fail (also for administrators who cannot see the extension)
**File:** `modules/cabana/crud.go:1353-1361` (mergedRules), `modules/cabana/form_extensions.go:405-443` (compileExtensionKinds accepts `Required`)
**Issue:** `compileFormExtensions` places extension fields into `cc.Form.Fields`. `mergedRules` loops over `cc.Form.Fields` and adds `required` for every `field.Required && valueFormField(field)` field, extension fields included. Then `lagoon.Validate` checks those rules against the owner's model. It reads the values through `virtualValuesForRules`/`valuesForRules`, which come from the model's columns. The extension field is not a column, and its value has already been lifted out of the body by `liftExtensionValues`, so the value is always nil and the rule always fails. I confirmed this in a scratch copy of the package. `parseExtensionDoc` accepts `required: true` with no error, and `mergedRules` returns `map[seo_title:required]` → `msgs=map[seo_title:[The seo title field is required.]]`. Nothing in `compileExtensionKinds` refuses or strips `Required`. The impact:
- Any extension that marks a field required, which is a natural thing to do for an SEO title or a slug override, bricks create/update of the host controller for everyone.
- `mergedRules` does not look at extension visibility, so it also hits administrators who do not hold the extension's `Permissions`.
- The 422 names a field that those administrators cannot see in the schema.
If the extension field's name matches a model column that is not on the owner's form, the rule silently validates the owner's column instead.
**Fix:** Keep owner validation away from extension fields. Then either refuse `required` at boot, or check it against the lifted value for visible extensions only:
```go
// crud.go mergedRules
for _, field := range cc.Form.Fields {
if cc.extensionOf(field.Name) != nil {
continue // D-91: an extension validates its own values (Save / AdminFormErrors)
}
...
}
```
and in `liftExtensionValues`, for a visible extension, add a "The X field is required." detail when `field.Required && contextAllows(...)` and the value is absent or empty (on create; on update only when the key is sent). If you choose to refuse it instead, add to `compileExtensionKinds`:
```go
if field.Required {
return extErr(pluginID, controller, "field %s: required is not supported in a form extension; refuse the value in Save with pact.AdminFormErrors", field.Name)
}
```
Add a Postgres test with a required extension field, saved by an administrator who sees the extension and by one who does not.
### CR-02: [CR-A02] Plugin field-type upload route ignores the extension's Permissions/Visible, so administrators without them can run its `Upload`
**File:** `modules/cabana/field_type_upload.go:32-126` (`fieldUpload`), `uploadField` at line 130
**Issue:** The other per-field routes of an extension gate on `extensionVisible`: `widgetAction` (actions.go), `partial` (partial_render.go), `formSchema` (http.go) and save/show. `POST .../{id}/fields/{field}/upload` does not. `uploadField` finds the field in `cc.Form.Fields`, which includes the extension's fields, and checks only `contextAllows`. `protect()` checks only the host controller's permissions. So an administrator with access to, say, `acme.blog.posts` but without `golem15.seo.manage` can still run the SEO plugin's `seoimage` `Upload` (D-100). The plugin stores a file in the bucket, inside a transaction on the loaded record, with no admin UI showing it. This is exactly the access that `Permissions` and `Visible` are documented to deny ("Its widget actions and partials answer 404 for them"), and the docs never list the upload route as an exception. It is an authorization gap on a write path.
**Fix:**
```go
field, ok := uploadField(cc, r.PathValue("field"), id)
if !ok {
writeNotFound(w, r)
return
}
if ext := cc.extensionOf(field.Name); ext != nil {
principal, _ := bouncer.User(r.Context())
if !extensionVisible(r.Context(), principal, ext) {
writeNotFound(w, r)
return
}
}
```
Add the upload route to the permission tests in `form_extensions_postgres_test.go`. Also audit any other route that resolves a field by name from `cc.Form.Fields` (`uploadField` is the only one I found that accepts extension types today).
### Warnings
### WR-01: [WR-A01] Admin writes that change or delete records fire no record event (hasMany link/unlink, deferred child unlink, deferred child commit)
**File:** `modules/cabana/relation.go:1013` (linkRelated hasMany `Save(related)`), `modules/cabana/relation.go:1316` (unlinkRelated hasMany `Save(child)` with FK nulled + ClearOnChange), `modules/cabana/relation.go:1130` (unlinkDeferred `Delete(child)`), `modules/cabana/deferred.go` (commitDeferred binding children to the new parent)
**Issue:** D-92 says "Admin, MCP and imports all go through cabana and emit it", and `pact.RecordSaved` says "The admin framework fires it for every admin write". The SEO plugin relies on it for 301s and for deleting entries with their record. Some admin paths still write records with no event:
- `unlinkDeferred` deletes a child that `CreateChild` created. `CreateChild` already fired `RecordSaved{Created: true}` for it after its commit, so subscribers learn about a record that later disappears without a `RecordDeleted`. The SEO entry (or any derived state) is orphaned.
- hasMany link and unlink rewrite the child's foreign key and clear its `ClearOnChange` columns (a parent change that can change the child's URL), with no `RecordSaved`.
**Fix:** In `unlinkDeferred`, after the delete, call `EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{Controller: s.reg.controllerForRecord(child), Model: recordMorphType(tx, child), ID: recordIDText(child)})`. In linkRelated and unlinkRelated (hasMany), take a snapshot before `setModelColumn` and call `s.emitChildSaved(ctx, tx, child, false, changes)` after `Save`. Do the same for the hasMany children `commitDeferred` attaches. Alternatively, narrow the `pact.RecordSaved` doc and `docs/backend/admin-controllers.md` to the paths that actually fire.
### WR-02: [WR-A02] `RecordSaved.Locale` is the admin's UI language or the raw `Accept-Language` header, not a content locale, and reorder reports something different
**File:** `modules/cabana/record_events.go:65`, `modules/cabana/relation_child.go:490`, `modules/cabana/reorder.go:318,337`
**Issue:** `pact.RecordSaved.Locale` is documented as "the request's content locale". `emitRecordSaved` and `emitChildSaved` copy `towel.Locale(ctx)`. In the admin that value is set by `surf.Router.locale` to the raw `Accept-Language` header when no `LocaleResolver` is published, for example `pl-PL,pl;q=0.9,en-US;q=0.8`, or by `surf.LocaleFromPrincipal` to the administrator's `PreferredLocale` (the UI language). Journal's `requestLocale` has to parse exactly this raw header, which shows it happens. `reorderPost` instead sets `towel.WithLocale(ctx, schemaLocale(...))`, so reorder events carry a normalised UI code. Admin saves write the default content locale to the row and report the other locales per change, so the event-level Locale has no defined content meaning. A subscriber that keys on it (an SEO 301 per locale) will mis-key.
**Fix:** Pick one meaning and enforce it at the emit site. For admin saves, set `Locale` to the default content locale (from `TranslationWriter.DefaultLocale`, or `""` without translate) and leave per-locale changes to `RecordChange.Locale`. At the very least, normalise with `primaryLanguage` the way `schemaLocale` does. Then document it in `pact/record_events.go`.
### WR-03: [WR-A03] A failing extension `Load` (or plugin-type `Load`) after a successful save rolls back the whole save
**File:** `modules/cabana/crud.go:980`, `modules/cabana/form_extensions.go:1431-1435`
**Issue:** `hydrateFormExtensions` runs as the last statement inside the save transaction closure, and its error is returned from it. The owner row, translations, deferred bindings and every extension's `Save` have all succeeded by then, but a read-side failure in third-party plugin code (an SEO `Load` hitting a missing row, a field type's `Load`) turns the save into a 500 and rolls everything back. `ShowRecord` likewise fails the whole host record when any one extension's `Load` fails. One contributing plugin can block editing of another plugin's records.
**Fix:** Run the response hydration after the transaction commits, in a short read transaction, or log the error and omit that extension's keys from `result.Data` instead of returning it. Keep `Save` errors as the rollback path; hydration is presentation only.
### WR-04: [WR-A04] Journal API writes can commit changes (including a slug) and fire no RecordSaved
**File:** `../sm-journal-plugin/controllers/api/posts.go:223-236` (Store), `:281-294` (Update)
**Issue:** The API handlers write without a transaction: `Save(post)`, then `applyPostAssocs` (categories, tags, and `syncTranslations`, which writes translated slugs), then `loadPostAssocs`. `cabana.EmitRecordSaved` is called only after all of them succeed. If `applyPostAssocs` or `loadPostAssocs` fails, the post row (and possibly some translations) is already committed but no event fires. A slug changed over MCP then gets no 301 (D-102), which is exactly what the change was for.
**Fix:** Wrap the write in `lagoon.Transaction(ctx, db, ...)` and call `cabana.EmitRecordSaved(ctx, app, tx, ...)` inside it, so the event follows the commit and a failure rolls back the row too. If that is not possible, emit as soon as `Save` succeeds and emit again (or emit the translation changes) after `applyPostAssocs`.
### WR-05: [WR-A05] Journal `postSavedEvent` reports `Old: nil` for a translated `content_html` that existed, because the body is mutated between the snapshot and the event
**File:** `../sm-journal-plugin/controllers/api/record_events.go:54,91`, `../sm-journal-plugin/controllers/api/posts_helpers.go:789`
**Issue:** `snapshotPost` reads the old translations of the fields `submittedTranslations(body)` lists *before* the write. `syncTranslations` then calls `classes.FormatTranslation(attrs)` on the same `attrs` map inside `body`, which adds `attrs["content_html"]` whenever `content` is sent. `postSavedEvent` calls `submittedTranslations(body)` again, now sees `content_html`, finds no snapshot for it and reports `Old: nil`. Per `pact.RecordChange`, nil means "a translation that did not exist before", so the event is wrong for every update that sends a translated `content` without `content_html`.
**Fix:** Compute the submitted set once, before the write, and pass it to both functions. Or include `content_html` in the snapshot whenever `content` is submitted (mirror `FormatTranslation`). Or copy the body's translation maps before `syncTranslations` mutates them.
### WR-06: [WR-A06] Tags auto-created by the Journal post API fire no RecordSaved although `journal-tag` is now a content type
**File:** `../sm-journal-plugin/controllers/api/posts_helpers.go:732-770` (`resolveTagIDs` → `db.Create(&tag)`)
**Issue:** D-102 adds the `journal-tag` content descriptor so that tags get SEO entries. The MCP post write creates new `models.Tag` rows on the fly and fires nothing for them. SEO and sitemap subscribers only learn about those tags from a later bulk job.
**Fix:** Collect the tags created in `resolveTagIDs` and emit `cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{Model: models.Tag{}.MorphName(), ID: ..., Created: true, Changes: ...})` for each, ideally inside the transaction suggested in WR-04 (WR-A04).
### WR-07: [WR-A07] `Hide` leaves the owner's relation-manager and partial endpoints of a hidden field reachable
**File:** `modules/cabana/form_extensions.go:624-641` (`dropHiddenFields`)
**Issue:** `dropHiddenFields` removes a hidden field from `Writable`, `files`, `dates`, `permissions` and `FieldRelations`, but not from `cc.Relations` (relation-manager panels and their link, unlink and child CRUD routes, plus `commitDeferred`, which loops over `cc.Relations` and calls `contextAllows`; that returns true for a field no longer in the form) or from `cc.partials`/`cc.formPartials`. The pact doc says hidden fields "leave the schema, show and save", but writes through the relation routes of a hidden relation-manager field still go through, and the partial still renders.
**Fix:** For hidden names, also drop the relation whose `fieldName` matches from `cc.Relations`, and drop the names from `cc.partials` and `cc.formPartials` (copy the maps first). Or refuse `Hide` of `relation-manager`/`partial` owner fields at boot.
### Info
### IN-01: [IN-A01] Dead check in `BindWritableFields`
**File:** `modules/cabana/crud.go:259-263`
**Issue:** `BindWritableFields` runs in `compileRegistryWithTypes`, before `compileFormExtensions` fills `cc.extensionFields`, and again for relation child forms, which never have extensions. So `cc.extensionOf(field.Name) != nil` is always false there. The comment suggests it protects something it does not.
**Fix:** Remove it, or move the guard to where it would matter and say so.
### IN-02: [IN-A02] Scoped API tokens never see an extension that declares no Permissions, and their posted values are dropped silently
**File:** `modules/cabana/form_extensions.go:1092-1097`, `modules/cabana/token_scopes.go:246-252`
**Issue:** `Allows` returns false under a non-full token scope when `required` is empty. An extension without `Permissions` is therefore invisible to scoped MCP tokens, and a save from such a token drops the extension keys and still answers 200. That is fail-closed, but it is surprising and undocumented in `pact.AdminFormExtension`.
**Fix:** Document it next to `Permissions`, or treat an empty `Permissions` as "inherit the host controller's access".
### IN-03: [IN-A03] `resolveExtensionLocales` wraps a nil error
**File:** `modules/cabana/form_extensions.go:1129-1131`
**Issue:** When `DefaultLocale` returns `("", nil)`, `fmt.Errorf("cabana: default locale: %w", err)` produces `%!w(<nil>)`. The same pattern exists in `hydrateMLRecord`.
**Fix:** Use `errors.New("cabana: default locale is empty")` when `err == nil`.
### IN-04: [IN-A04] `tab: default` on an extension field is refused although `default` names the default tab elsewhere
**File:** `modules/cabana/form_extensions.go:932-936`
**Issue:** The docs say "`default` names the tab of fields without a `tab:`". Anchors and `after:` accept it, but a field with `tab: default` fails with "unknown tab" because `place` does not map it through `tabAnchorKey`.
**Fix:** Normalise `field.Tab` with `tabAnchorKey` before the lookup, or say in the docs that a field omits `tab:` for the default tab.
### IN-05: [IN-A05] Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match
**File:** `../sm-journal-plugin/controllers/api/posts_helpers.go:806-819`
**Issue:** `pt-BR` and `en_GB` become `pt` and `en`. If translate stores region codes, the translated-slug lookup in `findPostBySlugOrID` misses.
**Fix:** Try the full normalised code first, then the primary language.
### IN-06: [IN-A06] The extension's client assets are served to every administrator of the host form
**File:** `modules/cabana/form_extensions.go:508-543`, `modules/cabana/http.go:851` (`view.Assets = s.controllerAssets(cc)`)
**Issue:** The plugin's `AdminClientAssets` JS/CSS is added to `cc.scripts`/`cc.styles` and loaded for every administrator, including those the extension is hidden from. This is not an authorization leak, because the scripts are already served, but it does run the plugin's script on forms where none of its elements exist.
**Fix:** Optional. Filter `controllerAssets` by visible extensions, or note the behaviour in the docs.
_Reviewed: 2026-10-27T13:28:44Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_
---
## Lane B: sm-seo-plugin core Go
```yaml
phase: 32-seo-foundation
reviewed: 2026-10-27T13:28:36Z
depth: standard
files_reviewed: 68
findings:
critical: 1
warning: 12
info: 8
total: 21
status: issues_found
```
### Phase 32: Code Review Report (scope B: sm-seo-plugin core)
**Reviewed:** 2026-10-27T13:28:36Z
**Depth:** standard
**Files Reviewed:** 68
**Status:** issues_found
### Summary
Reviewed the sm-seo-plugin v0.1.0 core: resolver and precedence, the redirect engine with loop and chain detection, normalisation and strict URLs, the 404 log, robots.txt, verification files, history and undo, batches, CSV transfer, the issue report, the `/_seo/api/v1` management API with its MCP tool table, and the admin controllers and extensions.
Auth is consistent. Every management route goes through `manage()` (`cabana.ResolveBackend` bearer-only, no cookie, so no CSRF surface, then `cabana.Allows(golem15.seo.manage)`). Every admin controller, action, bulk action, widget action and form extension declares `golem15.seo.manage`. The public endpoints (`/robots.txt`, `/resolve`, `/notfound`, `/verification/{name}`) are throttled. The 404 report token is compared in constant time. Open-redirect hardening on substituted targets (`SafeTarget`, `escapeCapture`) and the normalisation host pinning (`sameSite`) hold up under tracing. Absolute-URL building never uses the Host header. Verification files carry `nosniff` and `sandbox` CSP. Batches and API undo run in one transaction with savepoints. Admin record and bulk actions run inside cabana's action transaction (verified in `cabana/crud.go`).
Main concerns:
1. **Exact redirects whose source ends in `/` or has upper-case letters in its query never fire at runtime.** The resolver's on-demand DB lookup compares a different key than the in-memory set the save-time check uses. This also breaks "Create redirect…" from the 404 log on sites that use trailing slashes.
2. **The loop guarantee has holes.** The save-time simulation is not serialised, `AddressChanged` skips it entirely, and undo is not idempotent under concurrency.
3. **The public resolve endpoint can be driven to 500s and unbounded memory.** NUL or invalid UTF-8 paths cause the 500s; the never-evicted "logged cycle" map grows without bound.
4. **Validation differs between paths.** Social images, invalid statuses and locales are checked or rejected in the admin but accepted or silently coerced through the API, MCP, batches and imports.
5. **An overflowing `page` on export panics the handler.**
### Narrative Findings (AI reviewer)
### Critical Issues
### CR-03: [CR-B01] Exact redirects with a trailing slash or upper-case query characters never match at runtime
**File:** `../sm-seo-plugin/classes/redirects.go:119-137, 230-242, 277-289`
**Issue:** `LoadRedirectSet`, used by the resolver (`resolver.go:375`), the 404 log's "resolved" state (`controllers/notfound.go:70,205`, `controllers/api/notfound.go:123`) and `ResolveNotFound` (`notfound.go:161`), loads exact rules on demand with
`WHERE match = 'exact' AND lower(source) IN (keys)`. The keys come from `exactKeys(path, rawQuery)`, which strips one trailing slash from the path (`trimSlash`) and keeps the query in its original case. The stored `source` is never normalised: `models.Redirect.Normalize` only trims spaces. So:
- source `/old-page/` gives `lower(source)` = `/old-page/`, but every request key is `/old-page`. The row is never fetched, and the redirect is dead for both `/old-page` and `/old-page/`.
- source `/list?Page=2` gives `lower(source)` = `/list?page=2`, but the key is `/list?Page=2`. The row is never fetched.
I verified both mismatches with a scratch program. The save-time check (`CheckRedirect`, which uses `NewRedirectSet` over all rows in memory with no fetch), the issue report and the list's SQL `coveredSQL` filter all see these rules. So the admin saves them without complaint, the redirects list's "Resolved" SQL filter says the 404 entry is covered, the row badge says it is open, and visitors get a 404.
This hits real usage directly. On a site with `url_slash: add`, every 404 the frontend reports ends in `/`. **Create redirect…** / `redirect_not_found` stores `source = "/old/"`, which never fires. The next report of the same URL also finds no cover, so the admin can be offered "Create redirect" again, and that fails with `source_taken` instead of `has_rule`. Existing unit tests only exercise `NewRedirectSet` in memory (`classes/redirect_graph_test.go`), never the DB fetch path.
**Fix:** Make the DB lookup use the same key function as the in-memory set. Either normalise the stored lookup key at write time, or match on an expression index:
```go
// LoadRedirectSet fetch: compare the same normalised key exactKey() builds
err := db.Where(`match = ? AND (
rtrim(lower(split_part(source, '?', 1)), '/') ||
CASE WHEN position('?' in source) > 0 THEN '?' || substr(source, position('?' in source) + 1) ELSE '' END
) IN ?`, models.RedirectExact, keys).Order("id ASC").Find(&out).Error
```
A cleaner option is a stored `source_key TEXT` column, filled from `exactKey(r.Source)` in `SaveRedirect`, `AddressChanged` and the admin `FormBefore*`, with an index, and `WHERE source_key IN ?`. Keep `rtrim` semantics for `/` (the root stays `/`). Add a DB-backed test that resolves `/old/`, `/old` and `/list?Page=2` against stored exact sources.
### Warnings
### WR-08: [WR-B01] A query-specific exact redirect is silently shadowed by the path-only rule
**File:** `../sm-seo-plugin/classes/redirects.go:130-137, 281-289`
**Issue:** `exactKeys` returns `[path, path?query]`, and `Match` returns the first hit in that order. With `/products → /shop` and `/products?id=5 → /shop/item-5` both stored, `/products?id=5` always goes to `/shop`, so the more specific rule can never fire. `checkRedirectRules` does not report this: the probe's first hop is the other rule, so the `Hops[0].Rule.ID == r.ID` branches are skipped and the rule is saved without any notice.
**Fix:** Try the `path?query` key before the path-only key in `Match`. Alternatively, have `CheckRedirect` refuse or warn when the probe of `r` is answered by a different rule (a "shadowed" problem).
### WR-09: [WR-B02] `AddressChanged` writes redirects without validation or the loop simulation
**File:** `../sm-seo-plugin/classes/addresses.go:58-120`
**Issue:** Steps 1–4 use raw `tx.Create`, `tx.Save` and `tx.Delete` and never call `Problems()` or `CheckRedirect`. Only exact redirects whose source equals the new address are removed (step 1). Pattern and regex rules, case variants (`ignore_case`) and trailing-slash variants that match the new address survive. The new `old → new` 301 then forms a loop with them. For example, with a pattern `/a/* → /b/*` in place, a record moved from `/b/x` to `/a/x` gets `/b/x → /a/x → /b/x`. The resolver turns this into `not_found` for the record's live address. Re-pointing (step 3) can likewise close a cycle with pattern rules. Also, the descriptor URL is stored as an exact `source` unvalidated: an absolute or `//` URL from a descriptor would be stored and never match.
**Fix:** After steps 1–4, run `s.CheckRedirect(ctx, tx, redirect)` and the same for every re-pointed rule. On `Loop`, either skip creating the 301 and log, or delete the conflicting shadowing rule. Validate `res.Old` and `res.New` with `Problems(tx)` before creating.
### WR-10: [WR-B03] The save-time loop check races with concurrent writes
**File:** `../sm-seo-plugin/classes/redirect_graph.go:168-177, 276-310`; `controllers/api/auth.go:172-180`
**Issue:** `CheckRedirect` reads all rules inside the writer's transaction at READ COMMITTED with no lock. Two concurrent writes (two MCP agents, an import and an admin save, or a batch and a slug change) can each simulate without the other's uncommitted rule. For example, one creates `A → B` while the other creates `B → A`. Both commit, and the stored set contains the loop D-19 promises to refuse. The runtime guard turns it into a 404 for both addresses.
**Fix:** Serialise redirect writes. Take `SELECT pg_advisory_xact_lock(<const>)` at the start of `SaveRedirect`, `DeleteRedirect`, `AddressChanged` and `runBatch` before `LoadRedirects`, or run those transactions at `SERIALIZABLE` and retry on 40001.
### WR-11: [WR-B04] Undo is not idempotent under concurrent requests
**File:** `../sm-seo-plugin/classes/undo.go:71-83, 291-343`
**Issue:** `Undo` reads the revision with `undone_at IS NULL` without locking. It then restores and marks the revision with `UPDATE ... WHERE id = ?`, with no `undone_at IS NULL` condition and no `RowsAffected` check. Two simultaneous undo calls (a double-click in the admin plus a retry, or an MCP agent retrying) can both pass the "already undone" check and both restore. For "deleted" subjects that means two re-created rules, redirects or 404 rows (or a unique-violation 500). In every case it writes two `via undo` history rows. `UndoBatch` has the same pattern.
**Fix:** Lock the revision first, `tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Find(&rows)`. For batches, lock `WHERE batch_id = ? AND undone_at IS NULL FOR UPDATE`. Also make the final update conditional:
```go
res := tx.Model(&models.Revision{}).Where("id = ? AND undone_at IS NULL", rev.ID).Updates(updates)
if res.Error == nil && res.RowsAffected == 0 { return &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"} }
```
### WR-12: [WR-B05] Public resolve and 404 report answer 500 for NUL or invalid UTF-8 paths
**File:** `../sm-seo-plugin/classes/resolver.go:198-219`; `classes/notfound.go:59-80`; `controllers/api/resolve.go:31-40`
**Issue:** `parseTarget` and `NotFoundPath` return `u.Path`, which is percent-decoded. `/%00` or `/%FF` yields a path with a NUL or invalid UTF-8 byte. That path reaches Postgres as a text parameter (`lower(source) IN ?` in the redirect fetch, `url = ?` in `ReportNotFound`), and Postgres rejects it ("invalid byte sequence for encoding UTF8"). The anonymous resolve endpoint then answers 500 and writes a `log.Error` line per request: 600 per minute per IP, more with many IPs. The token-gated 404 report fails the same way for every such URL a visitor hits.
**Fix:** In `parseTarget` and `NotFoundPath`, refuse a decoded path that is not `utf8.ValidString` or contains `\x00`, returning `ErrBadURL` / `ErrNotFoundURL` (422). Apply the same check to `rawQuery`.
### WR-13: [WR-B06] The runtime cycle log grows without bound and is keyed by attacker-controlled URLs
**File:** `../sm-seo-plugin/classes/redirects.go:529-537`; `classes/resolver.go:358, 385, 635`
**Issue:** `redirectHopsLogged` is a process-global `sync.Map` that is never evicted. Its key is `self = site + path?rawQuery`, and the raw query comes straight from the anonymous request (up to 2048 bytes). Once any looping or over-10-hop chain exists (which WR-09 (WR-B02) and WR-10 (WR-B03) make reachable), an unauthenticated client can request `/looping?x=1`, `?x=2`, … Each request stores a new entry and writes a new warning line. Memory grows without limit, and the log is flooded despite the "once per URL" intent.
**Fix:** Key the de-duplication on the rule IDs of the cycle (or `npath` without the query), and bound it, for example with a small LRU or a time-based reset like `capWarned` in `notfound.go`.
### WR-14: [WR-B07] Export with a large `page` panics the handler
**File:** `../sm-seo-plugin/classes/transfer.go:159-168`; `controllers/api/transfer.go:88-89`
**Issue:** `start := (page - 1) * perPage` overflows for large `page` values. `page=9223372036854777` gives `start = -9223372036854775616`, which passes the `start > len(rows)` check, and `rows[start:end]` panics with "slice bounds out of range". I reproduced this. It is reachable from `GET /_seo/api/v1/export?page=…` and from the `seo_export_csv` MCP tool for any holder of `golem15.seo.manage`.
**Fix:** Clamp before multiplying:
```go
if page < 1 { page = 1 }
if page > out.Pages { page = out.Pages }
start := (page - 1) * perPage
```
Apply the same clamp in `listParams` (`controllers/api/auth.go:201-216`), where the multiplication can overflow into a negative offset.
### WR-15: [WR-B08] Public throttle buckets key on the SSR server's IP, capping the whole site
**File:** `../sm-seo-plugin/routes.go:91-111`
**Issue:** The README says the frontend server calls `/resolve` for every page it renders and reports every 404. Both calls are server-to-server, so `surf.ClientIP` resolves to the SSR host unless that host is configured as a trusted proxy and forwards `X-Forwarded-For`. `seo-resolve` (600/min) and `seo-notfound` (600/min) then become site-wide caps: about 10 rendered pages per second. Beyond that, resolve answers 429 and the frontend either fails or renders without the redirect decision and robots. Nothing in the plugin documents or enforces the trusted-proxy requirement.
**Fix:** Let the frontend server authenticate resolve calls with the existing `notfound_token` (or a dedicated `resolve_token`) and exempt or raise the bucket for authenticated calls. At minimum, document in the README that the SSR host must be in the trusted proxies and must forward the visitor IP.
### WR-16: [WR-B09] Social image values bypass validation on every non-admin write path
**File:** `../sm-seo-plugin/classes/owner.go:129-150`; `classes/rules.go:33-52`; `classes/settings_write.go:104-105`
**Issue:** The admin `seoimage` field type refuses anything but a path or an http(s) URL (`decodeImage` → `ValidImageURL`). `SetURLSEO` (API, MCP, batch `set_seo`, records import), `SaveRule` (API, MCP, batch, rules import) and `SaveSettings` (`default_og_image`) validate `canonical` and `robots` but not `og_image`. Values such as `javascript:alert(1)`, `data:…`, `//evil.example/x.png` or text with spaces are stored. `AbsoluteURL` (`urls.go:125-137`) passes any `IsAbs()` value through, so they come back from `/resolve` as `seo.og.image`. That value goes into `og:image` / `twitter:image` meta content, where crawlers and frontends trust it.
**Fix:** Add `case column == "og_image" && v != "" && !ValidImageURL(v):` to `SetURLSEO`. Add the same check for `r.OGImage` in `SaveRule` and for `default_og_image` in `SaveSettings`, using the `golem15.seo::lang.image.url_invalid` problem.
### WR-17: [WR-B10] Invalid redirect status, match or query values are silently coerced on API, MCP and import
**File:** `../sm-seo-plugin/classes/batch.go:53-78`; `models/redirect.go:109-129`
**Issue:** `RedirectFields.Apply` copies any status, match or query, and `Normalize` replaces unknown values with defaults. A client sending `"status": 308` or `307` gets a 301. `"match": "Regex"` or `"glob"` becomes `exact`, which then fails with a misleading `source_invalid`. `"query": "keep"` becomes `ignore`. All of this is answered with 201. The CSV import has the same behaviour for the `match` and `query` columns. An MCP agent cannot tell that its request was changed.
**Fix:** In `RedirectFields.Apply` (or a `Validate` step before `Normalize` on API, batch and import paths), refuse values outside {301, 302, 410}, {exact, pattern, regex} and {ignore, pass} with a `ProblemsError` (`value_invalid`). Leave the defaulting only to fields the caller omitted.
### WR-18: [WR-B11] `set_url_seo`, batch `set_seo` and the records import accept any locale
**File:** `../sm-seo-plugin/classes/owner.go:154-173`; `classes/entries.go:176-223`; `classes/transfer.go:536-568`
**Issue:** `in.Locale` (or the CSV `locale` cell) overrides the matched locale without checking that the record has that locale. `SaveRecord` creates an entry for `"xx"` or `"EN "` or any string, with an empty URL (`described[locale]` is missing), and records a history row. The answer says `written_to: record`, but the resolver never reads that row. The SEO tab never shows it either, so the agent's change is lost while it is reported as success.
**Fix:** In `SetURLSEO`, when `in.Locale` is set, require it to be one of `recordLocales(...)` (or the enabled locales from `Locales()`). Otherwise refuse with `locale_unknown` as a `ProblemsError`.
### WR-19: [WR-B12] Entry generation overwrites `entry.URL`, which can swallow an address change and its 301
**File:** `../sm-seo-plugin/classes/generate.go:111-131`; `listeners.go:46-70`
**Issue:** Address-change detection (`AddressChanges`) depends on `entry.URL` holding the last address seen. `refreshEntry` sets `url` to the current descriptor URL unconditionally, and the job is queued after every settings save (`settings_write.go:292`) or from the CLI. If a record's slug changes and the job runs before the `RecordSaved` listener's transaction, the listener sees `entry.URL == url` and no 301 is created. This happens with an after-commit listener racing a worker, or with any write path that does not emit `RecordSaved`. The old URL then 404s with no trace in history.
**Fix:** In `refreshEntry`, when an existing entry's URL differs from `l.URL` and the record is published, call `AddressChanged` (with via auto) instead of overwriting, or leave `url` untouched and let the listener or SEO tab own it. Only fill `url` when it is empty.
### Info
### IN-07: [IN-B01] The `regexCache` / `globCache` maps are never evicted
**File:** `../sm-seo-plugin/classes/redirects.go:45-72`; `classes/urls.go:13-29`
**Issue:** Every distinct source compiled during a save simulation, a dry-run batch, an import preview or a refused (looping) save stays cached for the life of the process. The input is admin/MCP-controlled (up to 512-character regexes), so this is bounded by trusted users, but a long-running MCP agent can grow it steadily.
**Fix:** Cache only stored rules (key by rule ID plus `updated_at`), or use a bounded LRU.
### IN-08: [IN-B02] CSV `unescapeCell` changes legitimate values that start with an apostrophe
**File:** `../sm-seo-plugin/classes/csv.go:50-57`
**Issue:** A stored title such as `'-Mix` is exported unescaped (it does not start with a formula character) and imported back as `-Mix`, because import strips any apostrophe followed by a formula character. Round-trips are therefore not lossless.
**Fix:** In `escapeCell`, also prefix values that already start with `'` (so `'x` exports as `''x`), and strip exactly one apostrophe on import.
### IN-09: [IN-B03] The transfer token has no expiry or user binding, and the fallback key is per process
**File:** `../sm-seo-plugin/classes/transfer.go:700-788`
**Issue:** The apply token carries the file and a fingerprint but no issue time or admin ID, so it can be replayed by any SEO manager at any later time while the data is unchanged. Without an app key, `fallbackKey` is random per process, so with several instances behind a load balancer a preview and its apply land on different keys and fail with `token_invalid`.
**Fix:** Add `iat` and `admin_id` to the token and check a TTL (for example 1 h) and the current principal on apply. Refuse to issue tokens when there is no app key, rather than using a per-process key.
### IN-10: [IN-B04] Uniqueness checks swallow query errors inside the save transaction
**File:** `../sm-seo-plugin/models/redirect.go:165-174`; `models/rule.go:110-119`
**Issue:** `if err := q.Count(&count).Error; err == nil && count > 0` ignores errors. On Postgres a failed statement aborts the transaction, so the next statement fails with "current transaction is aborted", which hides the real cause. If the check is skipped, the unique constraint later surfaces as a 500 instead of `source_taken` / `url_taken`.
**Fix:** Return the error, or map a unique-violation (23505) on create or save to the same problem key.
### IN-11: [IN-B05] `RenderTemplate` trims separators that belong to the content
**File:** `../sm-seo-plugin/classes/templates.go:29-40`
**Issue:** `strings.Trim(out, " \t|-–—·:,")` runs on the whole rendered string. A template that is just `%title%`, or a title such as `Q&A:` or `-20% sale`, loses its own leading or trailing characters (`Q&A`, `20% sale`).
**Fix:** Trim only the separators adjacent to placeholders that rendered empty. For example, render each placeholder with a sentinel, collapse `sentinel+separator` runs, then remove the sentinels.
### IN-12: [IN-B06] Body caps do not account for JSON escaping
**File:** `../sm-seo-plugin/controllers/api/robots.go:103`; `controllers/api/transfer.go:105-112`
**Issue:** `decode` truncates the body at the limit. A 500 KB robots.txt or a 900 KB CSV with many quotes or newlines exceeds the JSON body limit after escaping, so the caller gets a generic `422 The request body is invalid.` instead of `robots.too_large` or `413 too_large`.
**Fix:** Detect truncation (read `limit+1` and compare) and answer 413 with the documented code, or size the limits from the worst-case escaped size.
### IN-13: [IN-B07] `/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production
**File:** `../sm-seo-plugin/controllers/api/robots.go:20-46`
**Issue:** When `service()` is nil (before boot), `staging` stays false and the body is `FallbackRobots` (allow all). A staging instance answering during startup gives crawlers an allow-all file, which contradicts D-22's "outside production always Disallow".
**Fix:** When `svc == nil`, read the environment from the app config directly, or default to `NonProductionRobots` with `X-Robots-Tag: noindex, nofollow`.
### IN-14: [IN-B08] Self canonical and `url` are built from the decoded path
**File:** `../sm-seo-plugin/classes/resolver.go:358, 476-480`
**Issue:** `self := site + path` uses `u.Path`, which is decoded. A page at `/a%3Fb` gets canonical `https://site/a?b` (a different URL), and spaces or non-ASCII characters end up unescaped in `canonical`, `og.url` and `url`. Location headers already use `escapePath`, so canonical and redirect forms disagree.
**Fix:** Build `self` from `escapePath(path)` in both places.
_Reviewed: 2026-10-27T13:28:36Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_
---
## Lane C: sm-seo-plugin models, config, partials, elements, tests
```yaml
phase: 32-seo-foundation
reviewed: 2026-10-27T13:28:38Z
depth: standard
files_reviewed: 103
findings:
critical: 1
warning: 6
info: 9
total: 16
status: issues_found
```
### Phase 32: Code Review Report (scope C: sm-seo-plugin models, migrations, admin YAML, partials, elements, lang, tests, README)
**Reviewed:** 2026-10-27T13:28:38Z
**Depth:** standard
**Files Reviewed:** 103
**Status:** issues_found
### Summary
I reviewed the remaining sm-seo-plugin v0.1.0 files: GORM models and migrations, the admin list/form/filter YAML, the server-rendered `.htm` partials, the seven light-DOM custom elements, the en/pl phrasebooks, the Go and vitest suites, the build config and the README.
**What holds up:**
- **Partials.** They render through cabana's `html/template` renderer (`modules/cabana/partial_render.go`). It autoescapes, refuses trusted `template.HTML*` types in view models and sanitises the output. No partial interpolates raw HTML, so I found no XSS path in the `.htm` files.
- **Elements.** All of them build DOM with `textContent`, `createTextNode` and `setAttribute`. `innerHTML` is never used. Server-supplied links in the SERP duplicates are limited to single-slash paths.
- **Lang files.** Both phrasebooks have the same key set (pl adds only CLDR `few`/`many` forms) and the same `:param` sets. Every `golem15.seo::lang.*` key the Go, YAML and `.htm` files reference exists.
**The one blocker is cross-module.** The SERP preview widget and the robots.txt tester widget both send their first `summer-action` from `connectedCallback`. The framework's `WidgetField.vue` drops that event, because it assigns `element` only after `append()`. Both elements then keep `inflight` set forever, and every later request sits in the queue. Neither widget ever works in the real admin. The plugin's vitest suite does not catch this: it dispatches `summer-result` by hand and never goes through the host. I confirmed the timing with happy-dom from `admin/node_modules`: the listener sees `element === null`.
The warnings cover:
- inconsistent URL safety checks for the social image
- a CSV round trip that changes some values
- list filters that disagree with the runtime redirect matcher
- an uncapped indexed rule URL
- grammar drift between the TS and Go robots parsers
- gaps in the element tests
### Critical Issues
### CR-04: [CR-C01] The SERP preview and the robots.txt tester deadlock: their first action is dropped by the host and `inflight` is never cleared
**File:** `../sm-seo-plugin/assets/src/elements/serp.ts:172-178, 217-224`; `../sm-seo-plugin/assets/src/elements/robotstester.ts:77-82, 110-117`. Root cause in the framework: `admin/src/components/form/fields/WidgetField.vue:82, 161-162`.
**Issue:**
1. Both elements call `this.request({ op: 'context' })` in `connectedCallback`. `request()` sets `this.inflight = payload` and then dispatches `summer-action` synchronously.
2. `WidgetField.vue` registers its listener before mounting, but it runs `mountPoint.value.append(created)` (line 161) before `element = created` (line 162). `connectedCallback` runs inside `append()`.
3. `onAction` therefore reads `const target = element` as `null` (line 82) and returns without posting, without setting `busy` and without ever dispatching `summer-result` or setting `state=error`.
`inflight` is only cleared in `onResult` or on `state=error`, so it stays set for the element's lifetime:
- **`golem15-seo-serp`** (SEO tab and URL rule form) shows the skeleton with `aria-busy` forever. `scheduleDuplicates` exits early because there is no context, so no duplicate check is ever sent.
- **`golem15-seo-robotstester`** puts every **Test URL** click into `queued`. `flush()` only runs when `busy` is removed, and `busy` is never set. The tester never answers.
Reproduced with happy-dom (`admin/node_modules/happy-dom`) using the same create, listen, append, assign order: the listener observes `element === null`.
The same deadlock happens if the element is disconnected while a request is in flight: the `summer-result` listener is removed in `disconnectedCallback`, the result is lost, and reconnecting queues behind the stale `inflight`.
**Fix:** Do both. In the framework, assign the element before connecting it:
```ts
// WidgetField.vue onMounted
element = created
created.addEventListener(WIDGET_EVENT, listener)
mountPoint.value.append(created)
status.value = 'ready'
```
In the elements, do not trust that an emitted action was taken. The host sets `busy` synchronously inside `onAction` before its first `await`, so after `dispatchEvent` returns, a missing `busy` attribute means the action was dropped:
```ts
private request(payload: Payload): void {
if (this.hasAttribute('busy') || this.inflight) { this.queued = payload; return }
this.inflight = payload
emit(this, 'summer-action', { payload })
if (!this.hasAttribute('busy')) { // host did not take it
this.inflight = null
this.queued = payload
setTimeout(() => this.flush(), 0) // retry once the host is wired
}
}
// and defer the first request:
connectedCallback(): void { /* ... */ queueMicrotask(() => this.request({ op: 'context' })) }
// and reset on disconnect:
disconnectedCallback(): void { /* ... */ this.inflight = null }
```
Add a vitest case that mounts the element through a fake host with the same ordering, and a `robotstester.test.ts`. There is none today.
### Warnings
### WR-20: [WR-C01] The social image preview accepts `/\host/...`, so the admin can load an image from an attacker's host
**File:** `../sm-seo-plugin/assets/src/elements/image.ts:6-8`. Server side: `../sm-seo-plugin/classes/field_types.go:157-163` (`ValidURLRef`, used by `ValidImageURL`).
**Issue:**
- `safeImageURL` only refuses `//`: `/^\/(?!\/)/`.
- `ValidURLRef` likewise only refuses `//`.
- A stored `og_image` of `/\evil.example/x.png` passes both. Browsers normalise `\` to `/` in http(s) URLs, so `<img src="/\evil.example/x.png">` in the admin loads `https://evil.example/x.png`. That leaks each viewing administrator's IP and session timing to a third party.
- Any holder of `golem15.seo.manage`, an MCP caller or a CSV import can set the value.
- `models.validTarget` (`models/redirect.go:192-193`) already refuses `/\`, so the two validators are inconsistent.
**Fix:** Refuse `/\` in both places:
```ts
export function safeImageURL(url: string): boolean {
return /^\/(?![\/\\])/.test(url) || /^https?:\/\//i.test(url)
}
```
```go
if strings.HasPrefix(v, "/") {
return !strings.HasPrefix(v, "//") && !strings.HasPrefix(v, "/\\")
}
```
### WR-21: [WR-C02] The CSV export/import round trip changes cells that already start with an apostrophe followed by a formula character
**File:** `../sm-seo-plugin/classes/csv.go:43-57` (exercised by `classes/csv_test.go:60-70`; the claim is in `README.md:38`)
**Issue:**
- `escapeCell` adds `'` only when the cell starts with a formula character.
- `unescapeCell` strips a leading `'` whenever the next character is a formula character.
- A note, title or target whose real value is `'=x`, `'-)` or `'+48…` is therefore exported unchanged and comes back on import as `=x`, `-)` or `+48…`.
- The README promises that the import drops "again" what the export added, and `TestCSVTransfer` asserts that re-importing an export is all `unchanged`. That holds only because no test value starts with `'`. `csv_test.go` checks `unescapeCell("''=x")` but never the round trip of `'=x`.
**Fix:** Escape an apostrophe-led value too, so `unescapeCell` is the exact inverse:
```go
func escapeCell(s string) string {
if formulaStart(s) || (strings.HasPrefix(s, "'") && formulaStart(s[1:])) {
return "'" + s
}
return s
}
```
Then add `{"/ap", "/x", "'=x"}` to the round-trip rows in `TestCSV`.
### WR-22: [WR-C03] The 404 "Open/Resolved" filter and the redirect "Chains" filter disagree with the runtime matcher
**File:** `../sm-seo-plugin/models/notfound.go:44-61`, `../sm-seo-plugin/models/redirect.go:227-265`. The same scope backs MCP `list_not_found?status=` (`controllers/api/notfound.go:116`).
**Issue:**
- **`coveredSQL`** counts an entry as resolved only when an exact redirect's `source` equals the logged `url`, optionally ignoring case. The runtime also matches:
- the source with and without a trailing slash (`trailing_slash`, on by default)
- a source without a query against a logged URL with one (`query = ignore`)
- patterns and regexes
So `/missing/` or `/missing?utm=x` stays "Open" in the admin filter and in the MCP list after a redirect for `/missing` already answers it, while the row badge says "Resolved". The pagination totals are wrong too.
- **`chainSQL`** compares `next.source = target` literally. It misses targets written as the site's absolute URL, trailing-slash or case variants, and pattern or regex next hops, all of which the save-time check treats as chains. The "Only chains" filter therefore under-reports.
The code comment admits the pattern/regex gap. The trailing-slash and query gaps are not documented, and the MCP tool presents "open" as "no redirect answers the address yet".
**Fix:** Pick one approach:
- Compute the status from the compiled `RedirectSet` (as the badge does) and filter by IDs.
- Add the slash and query cases to the SQL, for example `r.trailing_slash AND rtrim(r.source,'/') = rtrim(split_part(url,'?',1),'/')` plus `r.query = 'ignore' AND r.source = split_part(url,'?',1)`.
- Persist a `resolved_by` column that is refreshed on redirect writes.
Also document the remaining gap in the MCP tool's `jsonschema` text.
### WR-23: [WR-C04] The URL rule address is uncapped but sits in a btree UNIQUE index, so long values return 500 instead of 422
**File:** `../sm-seo-plugin/models/rule.go:102-121`; `../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go:28`
**Issue:**
- `Rule.Problems` checks the prefix, whitespace and `*`, but not the length.
- `UNIQUE (url, match)` is a btree, and Postgres refuses index rows over about 2704 bytes ("index row size exceeds btree version 4 maximum"). Short highly repetitive strings may compress under the limit; random content does not.
- A long rule URL from the admin form, MCP `create_rule`/`set_url_seo` (which falls back to a rule) or a records CSV import (a row without a record becomes a rule) therefore fails at the database with a 500. A batch or import fails as a whole with an opaque error, not a per-row refusal.
- Redirects cap their sources at `MaxRedirectURL = 2048`; rules have no equivalent.
**Fix:** Mirror the redirect cap:
```go
const MaxRuleURL = 2048
if len(url) > MaxRuleURL || !strings.HasPrefix(url, "/") || ... {
out["url"] = []Problem{{Key: errRuleURLInvalid}}
return out
}
```
### WR-24: [WR-C05] The robots element's grammar drifts from the Go grammar, and an unparseable value is silently wiped on the next edit
**File:** `../sm-seo-plugin/assets/src/elements/robots.ts:39-98, 136-145`; test claim in `tests/elements/robots.test.ts:75-79`
**Issue:** `parseRobots` claims "the grammar is the same as `classes/robots_value.go`", but they differ in both directions:
| Input | TS `parseRobots` | Go `ParseRobots` |
|---|---|---|
| `unavailable_after:2027-02-30` | accepts (V8 `Date.parse` rolls it over to March) | refuses |
| `max-snippet:+5` | refuses (`/^-?\d+$/`) | accepts (`strconv.Atoi`) |
When `set value` gets a string TS cannot parse, the element:
- shows every group as "Inherit" (`parseRobots(next) ?? emptyRobots()`) while `current` still holds the stored value
- serialises the empty model on the first click of any control, which silently drops every stored directive
Today the server normalises the values it writes, so this needs a value written outside those validators: a direct DB write or migrated legacy data. The UI still misrepresents the stored state with no warning. The vitest "refused like the Go grammar" list omits the Feb-30 case, so the drift is untested.
**Fix:**
- Validate the date strictly: round-trip it through `new Date(Date.UTC(y, m-1, d))` and compare the parts.
- Accept an optional `+` like Go, or make Go strict.
- On a `null` parse, keep the raw value, render an "unrecognised value" note and disable the controls, or require an explicit reset, rather than showing "Inherit".
- Add `unavailable_after:2027-02-30` to the TS malformed list.
### WR-25: [WR-C06] The element tests never exercise the host protocol; the tester and the image element have no tests
**File:** `../sm-seo-plugin/tests/elements/serp.test.ts:27-38`; no `robotstester.test.ts` or `image.test.ts` exists.
**Issue:**
- The `preview()` helper appends the element, checks that `{op:'context'}` was emitted, then dispatches a `summer-result` itself. It never sets or clears `busy`, never sets `state=error`, and never models the host dropping or answering asynchronously.
- The `inflight`/`queued`/`flush` logic in serp, robotstester and transfer is therefore untested, which is how CR-04 (CR-C01) shipped.
- `golem15-seo-robotstester` (the queue, the `urlRequired` error, the context rebuild) and `golem15-seo-image` (upload id matching, error display, `safeImageURL`) have no tests at all.
**Fix:**
- Add a small fake host to `tests/elements/` that mirrors `WidgetField.onAction`: synchronously set `busy`, resolve on a microtask, dispatch `summer-result`, then remove `busy`, with a variant that drops the first event.
- Assert that a second request queued during `busy` is sent after `busy` clears, and that `state=error` unblocks the next request.
- Add `robotstester.test.ts` and `image.test.ts`.
### Info
### IN-15: [IN-C01] The regex length refusal puts an untranslated English phrase into the Polish UI
**File:** `../sm-seo-plugin/models/redirect.go:156`
**Issue:** `Params{"error": "longer than 512 characters"}` is substituted into `redirects.regex_invalid`. The pl admin shows "To nie jest poprawne wyrażenie regularne: longer than 512 characters".
**Fix:** Add a `redirects.regex_too_long` phrase (en/pl) with `:max`, and use it here.
### IN-16: [IN-C02] A migration comment is stale
**File:** `../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go:8-11`
**Issue:** The comment says "The robots.txt columns are added by a later migration", but robots.txt got its own table (`golem15_seo_robots`, migration 0008) and the settings table never gains such columns.
**Fix:** Delete the sentence.
### IN-17: [IN-C03] The SERP preview's template rendering is not single-pass like the Go renderer
**File:** `../sm-seo-plugin/assets/src/elements/serp.ts:44-53`
**Issue:** `renderTemplate` replaces placeholders one after another, so a title that contains `%site%` (or a site name that contains `%description%`) is expanded again. Go uses `strings.NewReplacer`, which runs in one pass. The preview can therefore differ from the served title, despite the comment saying "the way classes/templates.go does".
**Fix:** Use one pass: `tpl.replace(/%(title|description|site)%/g, (_, k) => vars[k] ?? '')`.
### IN-18: [IN-C04] `plural()` can throw on an unusual `ui-locale`
**File:** `../sm-seo-plugin/assets/src/elements/labels.ts:402-411`
**Issue:** `new Intl.PluralRules(locale)` is wrapped in `try`, but `new Intl.NumberFormat(locale)` in the same function is not. `uiLocaleOf` returns the first subtag of any `ui-locale` or `<html lang>` value, so an invalid tag (for example `1` or `x`) throws a `RangeError` during render and leaves the element blank.
**Fix:** Format the count through the existing `num()` helper, which already catches.
### IN-19: [IN-C05] Two test names do not describe what they assert
**File:** `../sm-seo-plugin/classes/owner_test.go:31`; `../sm-seo-plugin/classes/resolver_test.go:19-53`
**Issue:**
- The owner case "double slashes are normalised first" asserts `ErrBadURL`, because `url.Parse("//pages//about")` reads `pages` as the host. Nothing is normalised.
- The resolver "chains" subtests run `pick()` against layer orders the test defines itself. They do not exercise the resolver's real precedence, so a reordering in `resolver.go` would not fail them. The Postgres test covers the real path.
**Fix:** Rename the owner case. Either drop the synthetic chain loop or derive the chains from the resolver's actual candidate lists.
### IN-20: [IN-C06] The inner controls' accessible name is the machine field name
**File:** `../sm-seo-plugin/assets/src/elements/text.ts:85-88`; `../sm-seo-plugin/assets/src/elements/robotstxt.ts:174-177`
**Issue:** The host sets `field-name` to `props.field.name` (for example `seo_title`). The elements copy that into `aria-label`, so screen readers announce "seo_title" and the visible translated label is overridden.
**Fix:** Have the host pass a translated `field-label` attribute and use that, or rely on `aria-labelledby` pointing at the host's label id.
### IN-21: [IN-C07] The README test instructions skip the vitest suite, and the test script hides an empty run
**File:** `../sm-seo-plugin/README.md:227-236`; `../sm-seo-plugin/package.json:11`
**Issue:**
- The Testing section lists `npm ci && npm run typecheck && bash scripts/check-dist.sh` but never `npm test`, so the element tests are not part of the documented gate.
- `vitest run --passWithNoTests` makes a broken `include` glob pass silently.
- `check-dist.sh` builds with whatever `node_modules` already exists, not the committed lockfile it claims to use.
**Fix:**
- Add `npm test` to the README.
- Drop `--passWithNoTests`.
- Run `npm ci` unconditionally in `check-dist.sh`.
### IN-22: [IN-C08] Three partials are maintained twice
**File:** `../sm-seo-plugin/extension/_seo_effective.htm` / `controllers/rules/_seo_effective.htm`; `extension/_seo_status.htm` / `controllers/rules/_seo_status.htm`; `extension/_type_placeholders.htm` / `controllers/settings/_placeholders.htm`
**Issue:** These pairs are byte-identical copies, so a fix to one silently misses the other.
**Fix:** Keep one copy and resolve the other path to it, or add a test that asserts the pairs stay equal.
### IN-23: [IN-C09] The `disabled` branches in the widget elements are dead, and would be broken if reached
**File:** `../sm-seo-plugin/assets/src/elements/transfer.ts:265, 296, 378`; `robotstester.ts` `syncControls`; `styles.css:805-809`
**Issue:**
- `WidgetField.vue` never sets a `disabled` attribute on widget elements (only `PluginField` does), so these branches never run.
- If they did, `golem15-seo-transfer .seo-transfer__actions { display: flex }` would override the `hidden` attribute, and the Discard and Apply buttons would stay visible (though inert).
**Fix:** Remove the dead branches. If they are kept, add `[hidden] { display: none }` rules for the affected classes, as `.seo-robots__panel[hidden]` already has.
_Reviewed: 2026-10-27T13:28:38Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,56 @@
---
status: testing
phase: 32-seo-foundation
source: [32-VERIFICATION.md]
started: 2026-10-27T15:12:00.387Z
updated: 2026-10-27T15:12:00.387Z
---
## Current Test
number: 1
name: MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>.
expected: |
The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above.
awaiting: user response
## Tests
### 1. MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>.
expected: The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above.
result: [pending]
### 2. MCP session from a real Claude client linked to an admin with golem15.seo.manage: get_url_seo for a post URL; set_url_seo noindex by URL; apply_batch with dry_run true (include a redirect that would create a loop), then without the loop; undo_batch from history.
expected: The dry run returns the diff and conflicts and writes nothing. The loop is refused with redirect_loop. The applied batch is undone as a whole. A read-only grant lists only GET tools.
result: [pending]
### 3. Release decision for the post-review fixes: framework v0.5.2 (7 code commits after v0.5.1, unpushed), sm-seo-plugin v0.1.1 (20 fix commits after v0.1.0 plus a new migration, unpushed), sm-journal-plugin v0.2.6 (3 commits after v0.2.5, unpushed), then the demo bump.
expected: Jakub decides to release or hold. Until then, the released and mounted versions carry the 4 critical findings listed under 'Goal items that depend on unreleased fixes'.
result: [pending]
### 4. Confirm the meaning chosen for pact.RecordSaved.Locale in WR-02: the primary tag of the request locale, not the default content locale.
expected: Accept, or ask for the content-locale meaning before v0.5.2 freezes it
result: [pending]
### 5. Review the logic of the fixes the fixer flagged: WR-08 (exact path?query wins over path-only), WR-09 (address-change redirect validation and in-memory loop simulation), WR-19 (generation job routes an unseen URL change through AddressChanged), WR-22 (coversSQL as the SQL form of exactMatches).
expected: The matching and redirect-graph semantics are confirmed correct. DB tests pass: TestExactRedirectKeys, TestAddressChangeGuards, TestGenerateEntries, TestCoverAndChainFilters.
result: [pending]
### 6. Decide WR-03 (skipped): a failing extension Load after a successful save rolls back the whole save, and show fails the whole record.
expected: Either accept it as a known limitation for v0.5.x, or schedule an 'extension unavailable' state (a todo or a Phase 33 or later item).
result: [pending]
### 7. Judgment-tier prohibitions of 32-06: (a) nothing was pushed, tagged, deployed or posted without Jakub's go-ahead, and no version was hard-coded before the checkpoint; (b) the changelog token was never printed, copied or minted.
expected: Confirm, based on 32-06-release-log.md. The log records 'Task 4 answer (Jakub, 2026-10-27)' before step 1 and says the token was read by the script and never printed.
result: [pending]
## Summary
total: 7
passed: 0
issues: 0
pending: 7
skipped: 0
blocked: 0
## Gaps

View File

@@ -0,0 +1,264 @@
---
phase: 32-seo-foundation
verified: 2026-10-27T15:09:40Z
status: human_needed
score: 5/5 roadmap success criteria verified (plan must-haves: all automatable truths verified; 2 layout backstops and the UI-SPEC visual states routed to human)
covered_files:
- .planning/phases/32-seo-foundation/32-01-PLAN.md
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md
- .planning/phases/32-seo-foundation/32-02-PLAN.md
- .planning/phases/32-seo-foundation/32-02-SUMMARY.md
- .planning/phases/32-seo-foundation/32-03-PLAN.md
- .planning/phases/32-seo-foundation/32-03-SUMMARY.md
- .planning/phases/32-seo-foundation/32-04-PLAN.md
- .planning/phases/32-seo-foundation/32-04-SUMMARY.md
- .planning/phases/32-seo-foundation/32-05-PLAN.md
- .planning/phases/32-seo-foundation/32-05-SUMMARY.md
- .planning/phases/32-seo-foundation/32-06-PLAN.md
- .planning/phases/32-seo-foundation/32-06-SUMMARY.md
- admin/src/components/form/fields/WidgetField.vue
- modules/cabana/actions.go
- modules/cabana/contracts.go
- modules/cabana/crud.go
- modules/cabana/deferred.go
- modules/cabana/field_ml.go
- modules/cabana/field_type.go
- modules/cabana/field_type_upload.go
- modules/cabana/form_extensions.go
- modules/cabana/http.go
- modules/cabana/partial_render.go
- modules/cabana/record_events.go
- modules/cabana/relation.go
- modules/cabana/relation_child.go
- modules/cabana/reorder.go
- modules/pact/capabilities.go
- modules/pact/content.go
- modules/pact/record_events.go
- modules/signpost/content.go
- modules/signpost/signpost.go
- scripts/apisurface/main.go
- scripts/check-phase32.sh
covered_digest: "v2:sha256:3d160533886964f919d0b009ef28fe87e9b314c89b991e50bdff020197a3e7b3"
behavior_unverified: 2
overrides_applied: 0
behavior_unverified_items:
- truth: "E1 counter line at 320px container width: status text and count wrap onto two lines without overlap (32-06 backstop)"
test: "Open a Journal post's SEO tab in the admin, narrow the form column to about 320px, type a long SEO title"
expected: "The status text and the character/pixel count wrap onto two lines and do not overlap"
why_human: "tests/elements/text.test.ts asserts the CSS contract (flex, wrap, gap, no fixed width) under happy-dom, which has no layout engine; actual wrapping is not observable in the test"
- truth: "E2 at 360px: the two segmented groups stack and no segment label is clipped in either language (32-06 backstop)"
test: "Open the SEO tab's robots control at about 360px width in en and in pl"
expected: "Index and Follow groups stack on separate lines; no segment label is clipped"
why_human: "tests/elements/robots.test.ts checks the wrap rules, not rendered geometry"
human_verification:
- test: "MVP user-flow walk-through in the demo admin, built from the CURRENT (unreleased) heads: framework 6472f28, sm-seo-plugin ad419e9, sm-journal-plugin a6fc7cd. Open a Journal post, then a category, then a tag; open the SEO tab; watch the SERP preview load; fill title, description and keyphrase; save; change the post's slug and save; open SEO > Redirects; open SEO > robots.txt, edit, and use the tester; open Settings > SEO and check the per-type tabs. Then GET /_seo/api/v1/resolve?url=<old and new post URL>."
expected: "The SEO tab shows on posts, categories and tags. The SERP preview leaves its skeleton and shows the card, counters and duplicate warnings. Saving shows values back. The slug change shows the redirects.auto_created notice and a 301 old->new row appears. The robots tester answers allowed/blocked with the matching line. Settings shows General, one tab per content type, Indexing, Addresses, Verification. Resolve answers redirect 301 for the old URL and the effective SEO with sources for the new one. Also check the E1 320px and E2 360px layout items above."
why_human: "Visual rendering and the real WidgetField host. CR-04 showed that the fake-host element tests missed a real-admin deadlock in the released v0.5.1 + v0.1.0, so the fix (framework d660cd0 and plugin 3ecbd1f) has never been observed in a real browser."
- test: "MCP session from a real Claude client linked to an admin with golem15.seo.manage: get_url_seo for a post URL; set_url_seo noindex by URL; apply_batch with dry_run true (include a redirect that would create a loop), then without the loop; undo_batch from history."
expected: "The dry run returns the diff and conflicts and writes nothing. The loop is refused with redirect_loop. The applied batch is undone as a whole. A read-only grant lists only GET tools."
why_human: "External client (32-VALIDATION.md manual-only row). TestSEOToolsCatalog and TestMCPSetURLSeoTracer prove the catalog and the routes, not a live client session."
- test: "Release decision for the post-review fixes: framework v0.5.2 (7 code commits after v0.5.1, unpushed), sm-seo-plugin v0.1.1 (20 fix commits after v0.1.0 plus a new migration, unpushed), sm-journal-plugin v0.2.6 (3 commits after v0.2.5, unpushed), then the demo bump."
expected: "Jakub decides to release or hold. Until then, the released and mounted versions carry the 4 critical findings listed under 'Goal items that depend on unreleased fixes'."
why_human: "Pushing and tagging need Jakub's go-ahead (CLAUDE.md)."
- test: "Confirm the meaning chosen for pact.RecordSaved.Locale in WR-02: the primary tag of the request locale, not the default content locale."
expected: "Accept, or ask for the content-locale meaning before v0.5.2 freezes it"
why_human: "Public API semantics inside a patch release; the fixer flagged it for human confirmation."
- test: "Review the logic of the fixes the fixer flagged: WR-08 (exact path?query wins over path-only), WR-09 (address-change redirect validation and in-memory loop simulation), WR-19 (generation job routes an unseen URL change through AddressChanged), WR-22 (coversSQL as the SQL form of exactMatches)."
expected: "The matching and redirect-graph semantics are confirmed correct. DB tests pass: TestExactRedirectKeys, TestAddressChangeGuards, TestGenerateEntries, TestCoverAndChainFilters."
why_human: "Semantic correctness of matching and graph rules beyond what the tests encode (32-REVIEW-FIX.md asks for a human review)."
- test: "Decide WR-03 (skipped): a failing extension Load after a successful save rolls back the whole save, and show fails the whole record."
expected: "Either accept it as a known limitation for v0.5.x, or schedule an 'extension unavailable' state (a todo or a Phase 33 or later item)."
why_human: "Needs a design decision (32-REVIEW-FIX.md, Skipped Issues)."
- test: "Judgment-tier prohibitions of 32-06: (a) nothing was pushed, tagged, deployed or posted without Jakub's go-ahead, and no version was hard-coded before the checkpoint; (b) the changelog token was never printed, copied or minted."
expected: "Confirm, based on 32-06-release-log.md. The log records 'Task 4 answer (Jakub, 2026-10-27)' before step 1 and says the token was read by the script and never printed."
why_human: "unverified-prohibition, human review recommended. The verifier's verdict (consistent with the release log) is a non-authoritative LLM judgment."
---
# Phase 32: SEO foundation Verification Report
**Phase Goal:** As a site editor on any SummerCMS application, I want per-record and global SEO in the admin, with redirects and robots.txt, all manageable through MCP, so that every site ships correct, editable SEO without per-site work. The framework gains only generic seams; the SEO code lives in a new shared plugin.
**Verified:** 2026-10-27T15:09:40Z
**Status:** human_needed
**Re-verification:** No (initial verification)
**Mode:** mvp
**MVP format note:** `user-story.validate` answers `valid: false` ("Story must include ', I want to [capability],'"). The goal reads "I want per-record and global SEO …" with no "to". The narrowing rule was still applied, using the `[outcome]` clause ("every site ships correct, editable SEO without per-site work"), because the roadmap SCs give a precise contract. To meet the canonical regex, reword the goal through `/msd mvp-phase 32`, for example "I want to manage per-record and global SEO …".
## User Flow Coverage
| # | Step of the story | Expected | Evidence in codebase | Status |
|---|---|---|---|---|
| 1 | The editor opens a record of any registered content type | The SEO tab is there, and the content plugin does not import the SEO plugin | `extensions.go:27` builds one form extension per `reg.ContentTypes()`; Journal declares 3 `Content: &pact.ContentDescriptor` (`classes/menu_item_types.go:124,148,182`); marker stage: "journal: imports neither sm-seo-plugin nor sm-sitemap-plugin"; demo `TestSEOTabMounted` passes for posts (tabs Edit, Manage, Categories, SEO), categories and tags | ✓ |
| 2 | The editor edits per-record and global SEO | Values stored per (model type, record id, locale); settings with per-type tabs | `TestSEOTabTracer`, `TestSEOTab`, `TestSettingsTypeTabs`, `TestSEOTabPermission` pass (Docker) | ✓ (visual: human) |
| 3 | The frontend asks for the effective SEO of a URL | URL rule > record > global > derived, with sources | `GET /_seo/api/v1/resolve` → `Resolver.Resolve` (`controllers/api/resolve.go:31`); `TestResolverPrecedence` (unit, all layer masks, plus DB) passes | ✓ |
| 4 | The editor changes a slug | 301 old→new, chains flattened, loops refused | `TestSlugChange301`, `TestRedirectGraph` (back-and-forth), `TestAddressChangeFromEvent`, `TestLoopRefused` (admin, MCP, batch, import) pass | ✓ |
| 5 | The editor manages robots.txt and the AI bots | Served file, tester, staging lock, 11 bots incl. Claude-User | `TestRobotsServed`, `TestRobotsTester`, `TestRobotsMatcherGoogleExamples`, `classes/bots.go:27-37` | ✓ (tester in a real admin: human, CR-04) |
| 6 | An agent does all of it through MCP | One tool per admin operation, dry run, undo | 29 tools in `mcp.go`; `TestEveryAdminOperationHasTool` derives the operations from the admin controllers; `TestBatch`, `TestUndo`, `TestConcurrentUndo`; demo `TestSEOToolsCatalog` passes | ✓ (live client: human) |
| Outcome | "every site ships correct, editable SEO without per-site work" | The plugin mounts by `summer.yaml` alone; the framework stays generic | The demo mounts `golem15.seo` with no app code beyond the mount (`9451b31`, generated `plugins.gen.go`); marker: no SEO names under `modules/` or `docs/`. Frontend emission of the tags (Nuxt layer) is Phase 33 (D-97) by design | ✓ (backend side) |
## Goal Achievement
### Observable Truths (Roadmap Success Criteria)
| # | Truth | Status | Evidence |
|---|---|---|---|
| SC-1 | A plugin can add a field between two fields of another plugin's admin form (A, B plus C after A renders A, C, B), and add a tab between two existing tabs; unknown anchors, duplicate names and anchor cycles stop the boot with a clear error | ✓ VERIFIED | `compileFormExtensions(reg, plugins)` is wired at `modules/cabana/http.go:120`. I ran the following in this session: `TestFormExtensionPlacement` (subtests `C_after_A_gives_A_C_B`, `a_new_tab_between_two_tabs`, `a_tab-less_form_gains_the_default_tab_then_the_new_tab`), `TestFormExtensionBootErrors` (`unknown_field_anchor`, `unknown_tab_anchor`, `duplicate_field_name_*`, `duplicate_tab_key_*`, `an_anchor_cycle`, `an_anchor_outside_Requires()`, `a_fileupload_field`, zone refusals) and both matrices: all pass. `TestFormExtensionTracer` (Postgres) asserts the schema `[a c b]` and that the owner row is unchanged. The SPA backstop (FormView.test.ts, "form extensions in compiled order (UI-SPEC C1, D-91)") passes. |
| SC-2 | Journal posts, categories and tags show the SEO tab without importing `sm-seo-plugin`, and the frontend gets the effective SEO of any URL with the precedence URL rule > record > global > derived | ✓ VERIFIED | Demo `TestSEOTabMounted` (tagged) passes for posts, categories and tags. The marker stage passes the Journal import check. `TestResolverPrecedence` enumerates every layer mask per value. Note: per P-14 (32-03-PLAN), the per-type and general templates form the "global" layer above content, while the site-wide default description and default image are last-resort fallbacks after content. The approved UI-SPEC copy `settings.default_description_comment` states this. It is a documented reading of D-02, not a deviation. |
| SC-3 | Changing a record's slug creates a 301 and flattens existing chains; redirect loops are refused from the admin, MCP and imports | ✓ VERIFIED | Behavioral: `TestSlugChange301` (auto_created notice, 301 old→new, /pages/x flattened, pl locale), `TestAddressChangeFromEvent` (writers outside the admin), the back-and-forth case in `classes/redirect_graph_test.go:87` and `redirects_test.go:293`, and `TestLoopRefused` (admin 422 redirects.loop with nothing saved; MCP `redirect_loop`; MCP batch; CSV import preview `row_loop` and apply `import_refused`). Post-review hardening: `TestAddressChangeGuards` and `TestConcurrentRedirectWrites`. All pass. |
| SC-4 | Every SEO operation in the admin is available as an MCP tool, and a batch change can be previewed with dry-run and undone from history | ✓ VERIFIED | `TestEveryAdminOperationHasTool` derives the required operations from the admin controllers' config, actions and extensions (at least 20 operations) and checks that each one maps to a declared tool bound to a registered `/_seo/api/v1/` route. `TestMCPReadToolsAreGET` passes. All 29 management handlers go through `manage()` (401 without an admin, 403 without `golem15.seo.manage`). `TestBatch` checks that a dry run leaves a snapshot of every table unchanged and that a loop refuses the whole batch. `TestUndo` covers stale, loop, gone and batch cases, and `TestConcurrentUndo` passes. |
| SC-5 | The framework release with the seams is tagged, and the new code has unit tests, delivered in the phase's last plan | ✓ VERIFIED | `v0.5.1` (tag object aa6e0a5, commit 15b0d0d) is on origin (`git ls-remote` confirmed); `v0.1.0` (9cf0447) and `v0.2.5` are on origin; the demo go.mod requires all three and its submodules are at those tags. The 32-06 unit tests exist and pass: cabana matrices, `TestRecordEventDiff`, signpost `TestContent`, plugin `./classes` (resolver, graph, normalise, robots, templates, CSV, owner, issues) and 77 vitest element tests. |
**Score:** 5/5 roadmap success criteria verified (0 present-but-behavior-unverified among the SCs).
### Plan must-haves (summary)
| Plan | Truths | Result |
|---|---|---|
| 32-01 (D-91 seam) | 15 incl. 1 backstop | All verified by `TestFormExtension*` (unit and Postgres) and the FormView C1 backstop. "admin/src unchanged by this plan" holds at v0.5.1 (`git diff v0.5.0 v0.5.1 -- admin/src` is empty). WidgetField.vue changed later in the CR-04 fix, outside this plan. |
| 32-02 (D-92, D-93, Journal) | 11 | Verified: `TestRecordEvents` (create, update, translated slug, rollback, failing listener, reorder, child write, hasMany link and unlink, deferred children, delete, bulk delete), `TestRecordSavedDescribesNewURL`, `TestAPIRecordEvents`, `TestContentDescriptors(Matrix)`, `TestJournalTagType(Pages)`, `TestTranslatedSlug`. |
| 32-03 (plugin core) | 32 | Server-side truths verified (`TestSEOTabTracer`, `TestSEOTab`, `TestSEOTabPermission`, `TestSettingsTypeTabs`, `TestResolverPrecedence`, `TestHistoryRecordsAdminWrite`, `TestEntryDeletedWithRecord`, `TestGenerateEntries`, `TestRuleFormPreview`). The UI-SPEC visual states (skeletons, callouts, badges, truncation) go to the human walk-through. |
| 32-04 (redirects, 404, robots) | 21 | Verified: `TestRedirectEngine`, `TestExactRedirectKeys`, `TestNormalize(Matrix)`, `TestStrictURL`, `TestOneHop`, `TestNotFoundLog`, `TestRobotsServed`, `TestRobotsTester`, `TestRobotsScreen`, `TestVerificationFile`, `TestCoverAndChainFilters`. No root route for verification files (`routes.go:36-41`). The staging lock reads only `cfg.Environment()` (`classes/resolver.go:143`). The UI list and screen visuals go to human. |
| 32-05 (MCP, batch, CSV, gate) | 14 | Verified: `TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues` (8 kinds in `classes/issues.go:15-23`), `TestCSVTransfer`, `TestBatch`, `TestUndo`. The demo mount was later done for real (`9451b31`). `check-phase32.sh` has every stage. |
| 32-06 (tests, release) | 8 plus 8 backstops | 6 backstops verified by passing vitest (E1 thresholds, E2 grammar, E3 title and truncation, E4 lint per rule, E6 gating, E3 stem matching). 2 layout backstops (E1 320px, E2 360px) are ⚠️ PRESENT_BEHAVIOR_UNVERIFIED. Release truths verified against git and the release log. |
### Prohibitions
| Prohibition | Tier | Disposition |
|---|---|---|
| No breaking framework API change (32-01, 32-02, 32-06) | test | ✓ `check-phase32.sh --additive` passes (HEAD vs v0.5.1: +0 lines in cabana, pact, signpost). The release log records v0.5.1 vs v0.5.0: +2, +87, +8 lines, nothing removed. The self-test proves the stage refuses removals, signature changes and new interface methods. `--compat`: 23 siblings OK. |
| Extensions never write owner columns, translation rows or attachments | test | ✓ `TestFormExtensionTracer` (owner row asserted), `a_fileupload_field` boot error |
| Framework and docs name no SEO concept or consuming app (D-90) | test | ✓ marker stage |
| No record event for an uncommitted write | test | ✓ `TestRecordEvents/rollback` |
| Content plugins never import sm-seo-plugin; SEO data not in content tables | test | ✓ marker stage; `golem15_seo_entries` table |
| Resolver never answers unpublished SEO and never uses the Host header | test | ✓ `a_draft_is_not_found`; no `r.Host` use in the plugin |
| Non-production lock not switchable from admin or MCP | test | ✓ `Staging()` reads only the environment; `the_staging_lock` |
| Loops never saved (admin, MCP, import) | test | ✓ `TestLoopRefused` |
| /robots.txt never 5xx | test | ✓ `TestRobotsServed` (see IN-13 in Anti-Patterns) |
| Regex capture or query never makes an internal target external | test | ✓ `redirects_test.go:314-328`, `:423` |
| Batch or import never partial | test | ✓ `TestBatch`, `TestLoopRefused/import` |
| Read grant can't write; every management route needs golem15.seo.manage | test | ✓ `TestMCPReadToolsAreGET`; all 29 handlers wrap `manage()` |
| Undo never silently overwrites a newer change | test | ✓ `TestUndo` (undo_stale), `TestConcurrentUndo` |
| Nothing pushed, tagged or posted without Jakub's go-ahead | judgment | ⚠️ flagged (unverified-prohibition, human review recommended). The LLM verdict is that the release log is consistent with it. |
| Changelog token never printed, copied or minted | judgment | ⚠️ flagged (same) |
### Required Artifacts
| Artifact | Status | Details |
|---|---|---|
| `modules/pact/capabilities.go` (`Form *AdminFormExtension`) | ✓ VERIFIED | line 436; types at line 518 and onward |
| `modules/cabana/form_extensions.go` | ✓ VERIFIED | 1591 lines; compile, placement, save and hydrate |
| `modules/pact/record_events.go`, `content.go` | ✓ VERIFIED | RecordSaved, RecordDeleted, ContentDescriptor |
| `modules/cabana/record_events.go` | ✓ VERIFIED | `EmitRecordSaved`; emitted at `crud.go:971` inside the save transaction (after commit) |
| `modules/signpost/content.go` | ✓ VERIFIED | `MatchPath` and the rest; `TestContent` |
| `../sm-journal-plugin/classes/content_descriptors.go` | ✓ VERIFIED | 3 descriptors wired in `menu_item_types.go` |
| `../sm-seo-plugin/*` (plugin.go, extensions.go, classes/resolver.go, migrations, assets/dist/seo.js, mcp.go, operations.go, classes/batch.go, classes/undo.go, classes/redirect_graph.go, classes/addresses.go, classes/robots.go) | ✓ VERIFIED | All present and substantive; `check-dist.sh`: "assets/dist matches a fresh build" |
| `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` and the STACK.md row | ✓ VERIFIED | D-99 |
| `scripts/check-phase32.sh`, `scripts/apisurface/main.go` | ✓ VERIFIED | `--all` exit 0 in this session |
| `32-05-demo-mount.patch`, `../sm-demo-app/seo_mcp_test.go` | ✓ VERIFIED | applied in `9451b31`; `TestSEOToolsCatalog` passes |
### Key Link Verification
| From | To | Via | Status |
|---|---|---|---|
| cabana `http.go` | `form_extensions.go` | `compileFormExtensions(reg, plugins)` (line 120) | ✓ WIRED |
| cabana `crud.go` | the extension's Save | `saveFormExtensions` in the save transaction (line 965) → `ext.def.Save(ctx, pact.AdminFormSave{…})` (form_extensions.go:1394) | ✓ WIRED |
| cabana `crud.go` | `record_events.go` | `emitRecordSaved(ctx, s.app, tx, …)` (line 971) | ✓ WIRED |
| docs/backend/admin-controllers.md | example_form_extension_test.go | `src=` snippet (line 561); docs stage passes | ✓ WIRED |
| Journal `controllers/api/posts.go` | `cabana.EmitRecord*` | lines 237, 239, 305, 307, 349 | ✓ WIRED |
| Journal `menu_item_types.go` | `pact.ContentDescriptor` | `Content: &pact.ContentDescriptor` ×3 | ✓ WIRED |
| seo `extensions.go` | signpost `ContentTypes()` | line 27 | ✓ WIRED |
| seo `controllers/api/resolve.go` | `Resolver.Resolve` | line 31 | ✓ WIRED |
| seo `listeners.go` | `*pact.RecordDeleted` / `*pact.RecordSaved` | lines 23, 30; `AddressChanged` at line 65 | ✓ WIRED |
| seo `extensions.go` | `AddressChanged` | line 337 (in the save transaction) | ✓ WIRED |
| seo `routes.go` | `/robots.txt` | line 33, throttled, with no backend middleware | ✓ WIRED |
| seo `mcp.go` | `routes.go` | every tool's Method+Path is a registered route (parity test) | ✓ WIRED |
| `check-phase32.sh` | apisurface / Journal worktree compat | additive and compat stages pass | ✓ WIRED |
| `sm-demo-app/go.mod` | sm-seo-plugin | `sm-seo-plugin v0.1.0`; submodule at b19cd06 (v0.1.0) | ✓ WIRED |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|---|---|---|---|---|
| SEO tab fields | entry values | extension Load/Save on `golem15_seo_entries` in the owner transaction | yes (`TestSEOTabTracer`) | ✓ FLOWING |
| `/resolve` answer | effective SEO plus sources | rules, entries, type settings, settings and signpost Describe (DB) | yes (`TestResolverPrecedence` DB part) | ✓ FLOWING |
| E3 SERP preview | context and duplicates | widget actions → plugin `Run` → DB | yes in tests through a fake host; real host per the CR-04 fix (human) | ✓ FLOWING (real-host: human) |
| MCP tools | all SEO data | `/_seo/api/v1` handlers → classes.Service → DB | yes (`TestManagementAPI`) | ✓ FLOWING |
### Behavioral Spot-Checks and Probe Execution (run in this session)
| Check | Command | Result | Status |
|---|---|---|---|
| Seam unit tests | `go test -short ./modules/cabana ./modules/signpost ./modules/pact -run 'TestFormExtension\|TestRecordEventDiff\|TestRecordEventLocale\|TestContent'` | ok | ✓ PASS |
| Seam Postgres tests | `go test ./modules/cabana -run 'TestFormExtensionTracer\|…Save\|…Required\|…Upload\|TestRecordEvents'` | 5 top-level, 19 subtests PASS | ✓ PASS |
| Journal | `go test ./... -run 'TestContentDescriptors\|TestJournalTagType\|TestTranslatedSlug\|TestAPIRecordEvents\|TestRecordSavedDescribesNewURL'` | 8 PASS | ✓ PASS |
| sm-seo-plugin full suite (Docker) | `go test -count=1 ./...` | 55 top-level PASS, 0 FAIL | ✓ PASS |
| Plugin elements | `npm run test` + typecheck + `scripts/check-dist.sh` | 77/77; dist matches | ✓ PASS |
| Admin SPA | `npm --prefix admin run test -- FormView WidgetField` | 84/84 | ✓ PASS |
| Demo mount | `go test -tags summercms_mcp -run 'TestGoModRequiresTags\|TestSEOTabMounted\|TestSEOToolsCatalog' .` | 3 PASS | ✓ PASS |
| Phase gate (probe) | `bash scripts/check-phase32.sh --all` | exit 0, "Phase 32 gate passed" (self-test, go, plugins, consumers, admin, docs, marker, additive vs v0.5.1, compat across 23 siblings, docker) | ✓ PASS |
### Requirements Coverage
REQUIREMENTS.md has no IDs for this phase. The plans declare 51 IDs, and each is accounted for against 32-DECISIONS-MAP.md:
| IDs | Source plans | Status | Evidence |
|---|---|---|---|
| SC-1..SC-5 | 32-01..32-06 | ✓ SATISFIED | table above |
| D-90, D-91, D-92, D-93, D-98, D-100, D-101, D-104, D-106 | 32-01, 32-02, 32-06 | ✓ SATISFIED | seams, marker, additive and compat, boot errors (zone, fileupload), settings `AdminSingleton` (`controllers/settings.go:134`) |
| D-94, D-95, D-96, D-102, D-58 | 32-02, 32-03 | ✓ SATISFIED | D-95's Phase 32 part: `Requires()` returns nil (`plugin.go:51`); Journal descriptors, journal-tag and API events; translated slug |
| D-01..D-09, D-13, D-15, D-21, D-23 | 32-03 | ✓ SATISFIED | entries table, rules, settings, resolver, robots grammar, the one permission, SERP and counters (real-host rendering: human) |
| D-05, D-06, D-08, D-16..D-20, D-20a/b/c, D-22, D-24, D-25, D-99, D-103, D-105 | 32-04 | ✓ SATISFIED | redirect engine, 404 log, normalise, strict, robots and tester, bots incl. Claude-User, verification API only |
| D-10..D-14, D-19 | 32-05 | ✓ SATISFIED | 29 tools, owner resolution, batch, undo, CSV, issues |
| Q-01 | 32-06 | ✓ SATISFIED | patch tag v0.5.1 computed at release |
Orphaned: none. Every map ID with a Phase 32 column entry appears in a plan's `requirements`. Phase-33-only IDs (D-30..D-34, D-40..D-46, D-50..D-57, D-70..D-72, D-80..D-86, D-97) are out of scope by design.
### Goal items that depend on unreleased fixes
The released and mounted set is framework v0.5.1, sm-seo-plugin v0.1.0, Journal v0.2.5 and sm-demo-app 9451b31. Everything above was verified at the local heads, which carry the review fixes (unpushed: framework 16 commits after v0.5.1, of which 7 are code; sm-seo-plugin 21 after v0.1.0; Journal 3 after v0.2.5). In the released versions:
| Goal item | Finding | Released behavior | Fixed in (unreleased) |
|---|---|---|---|
| SERP preview (D-09, E3) and robots.txt tester (D-22, E5) in the real admin | CR-04 | Deadlock: the skeleton shows forever and the tester never answers | framework d660cd0 (WidgetField.vue and the dist), plugin 3ecbd1f (ActionQueue with retry). Either release alone fixes it. |
| Exact redirects (D-16, D-17, SC-3 runtime) | CR-03 | Sources with a trailing slash or a mixed-case query never fire | plugin 2d81c26 plus the new migration `202610280001_add_golem15_seo_redirects_source_key` |
| Loops refused on every path (SC-3, D-19) | WR-09, WR-10 | Address-change 301s skip validation and the loop simulation; the loop check races | plugin cb614cb, c2f136a |
| Slug change through Journal API or MCP fires the event (SC-3, D-102) | WR-04, WR-06 | A slug can commit with no RecordSaved on a failing association; auto-created tags fire nothing | Journal 62c75b1, a6fc7cd (WR-05: 7a03640) |
| Generation job keeps the 301 (SC-3) | WR-19 | The job can overwrite entry.URL and swallow an address change | plugin d8aa989 |
| Undo (SC-4) | WR-11 | Concurrent undo is not idempotent | plugin d20f14f |
| MCP/import validation (SC-4) | WR-16, WR-17, WR-18, WR-23 | Invalid values are silently coerced or answer 500 | plugin c8e0961, 24e6f38, cff2d29, 4ca26e9 |
| D-91 seam generality (SC-1) | CR-01, CR-02, WR-07 | `required` extension fields break host saves; uploads ignore extension visibility; hidden fields' relation and partial routes stay reachable | framework 676ca53, 5471b66, 0e187cf. The SEO plugin itself is not exposed: it declares no `required` fields, and its `seoimage` Upload checks `golem15.seo.manage` itself (32-03 deviation 6). |
| D-92 coverage | WR-01, WR-02 | hasMany link/unlink and deferred children fire no events; Locale is inconsistent | framework 468af06, 1583651 |
The additive stage shows +0 exported lines since v0.5.1, so plugin v0.1.1 needs no new framework API. However, the CR-02, WR-01 and WR-07 fixes reach consumers only through v0.5.2.
### Anti-Patterns Found
| File | Pattern | Severity | Impact |
|---|---|---|---|
| (all phase files in 4 repos) | TBD/FIXME/XXX | none found | — |
| sm-seo-plugin, cabana | 23 open info findings (IN-01..IN-23, 32-REVIEW-DISPOSITION.md) | ℹ️ Info | Not blocking. Notable: IN-13 (`/robots.txt` serves allow-all without the noindex header when the service is nil, even outside production; this is an edge of the D-22 lock in a boot-failure state); IN-05 (Journal `requestLocale` drops the region, so translated slugs in region-coded locales never match, which narrows D-58); IN-09 (transfer token has no expiry or user binding) |
| modules/cabana/crud.go:982 | WR-03 skipped | ⚠️ Warning | A failing extension Load turns a committed save into a rollback and a 500 (design decision pending) |
| 32-VALIDATION.md | stale status | ℹ️ Info | Still `status: draft`, with 32-06-T4 and T5 rows "pending" although both completed and `--all` is green. Update it to validated. |
| sm-seo-plugin README | WR-15 documentation-only fix | ℹ️ Info | The public throttle is site-wide unless the SSR host is in `http.trusted_proxies`; a deploy concern for Phase 33 layer integration |
### Human Verification Required
1. **MVP user-flow walk-through on the current heads.** Covers the SEO tab, SERP preview, slug-change 301, robots tester and settings tabs, plus the E1 320px and E2 360px layout. CR-04 proved that the fake-host tests can miss a real-admin failure.
2. **Live MCP client session.** get/set by URL, dry-run batch with a loop, apply, undo the batch, check the read-only grant.
3. **Release decision for the fixes** (v0.5.2, v0.1.1, v0.2.6, then the demo bump). The released set carries the 4 critical findings.
4. **WR-02**: confirm the meaning of `RecordSaved.Locale`.
5. **Logic review** of WR-08, WR-09, WR-19 and WR-22.
6. **WR-03**: design decision.
7. **Judgment-tier prohibitions** (release go-ahead, token handling): confirm against the release log.
### Gaps Summary
No blocking gaps. Each roadmap success criterion is backed by passing behavioral tests run in this session against the local heads of the four repositories, and the phase gate `check-phase32.sh --all` passes again (exit 0). The framework carries only generic seams (marker and additive stages), and the SEO code lives in sm-seo-plugin. Status is `human_needed` for three reasons:
- The visual and real-host behavior of the elements has never been observed in a browser, which matters because CR-04 slipped past the element tests.
- A live MCP client session is still to be run.
- The released versions (v0.5.1, v0.1.0, v0.2.5, mounted in the demo) predate 28 review fixes, including 4 critical ones. The goal is met in the codebase, but sites on the released tags get a broken SERP preview and robots tester and dead trailing-slash exact redirects until the fixes are released.
---
_Verified: 2026-10-27T15:09:40Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -0,0 +1,23 @@
# Phase 32 deferred items
Work left out of plan 32-06 on purpose, each with its next command.
## Not named in the release go-ahead (Task 4)
1. **sm-grzybyfunkcjonalne-app: Journal bump.** The app requires sm-journal-plugin as a pseudo-version (`v0.0.0-00010101000000-000000000000`, through a replace) and SummerCMS v0.4.4. Journal v0.2.5 needs SummerCMS v0.5.1, and its layer has no tag pages, so the bump sets `golem15.journal.tag_pages: false`. Next, after Jakub names it:
```sh
cd ../sm-grzybyfunkcjonalne-app
GOWORK=off GOPRIVATE='git.golem15.com/*' go get git.golem15.com/golem15/summercms@v0.5.1 git.golem15.com/golem15/sm-journal-plugin@v0.2.5
git -C plugins/golem15/journal fetch --tags origin && git -C plugins/golem15/journal checkout v0.2.5
# config: golem15.journal.tag_pages: false
go build ./... && go test -short ./...
git add go.mod go.sum plugins/golem15/journal <config file> && git commit -m "chore: bump journal to v0.2.5"
```
2. **sm-summercmsio-app: Journal bump.** The app requires sm-journal-plugin v0.1.0 and SummerCMS v0.5.0. Next, after Jakub names it: the same `go get` of `summercms@v0.5.1` and `sm-journal-plugin@v0.2.5`, the submodule checkout at v0.2.5, build and tests, commit `chore: bump journal to v0.2.5`. Decide whether the changelog site wants tag pages (`golem15.journal.tag_pages`).
3. **Plugin release posts.** summercms.io/changelog also carries posts for plugin releases (for example "sm-sociallogin-plugin 0.1.5"). Only the framework post for v0.5.1 was named; sm-journal-plugin v0.2.5 and sm-seo-plugin v0.1.0 have no post of their own (both are listed under "Released together" in the v0.5.1 post). Next, if wanted: the same Journal API post by hand (`../sm-summercmsio-app/DEPLOY.md`, "Posting to the changelog", step 3).
4. **Deploy.** Nothing was deployed. demo.summercms.io runs the previous demo build until a deploy per `../sm-demo-app/DEPLOY.md` (the new `location = /robots.txt` nginx block ships with it).
## Found during the release, not this plan's
- sm-demo-app: ` M plugins/golem15/oauthserver` (checked out b6a9586, recorded 8bbec72 = v0.1.2) is another session's uncommitted drift. It makes `scripts/check-pins.sh` fail in the live tree; the committed tree passes (`check-pins: ok (17 modules)` in a clean worktree). Left as found.
- sm-demo-app: `github.com/gabriel-vasile/mimetype v1.4.15` (indirect) is retracted upstream; `go get` warns. Pre-existing; next: `GOWORK=off go get github.com/gabriel-vasile/mimetype@latest` in a dependency-bump commit.

View File

@@ -0,0 +1,249 @@
---
phase: 32.1
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/pact/mcp_call_test.go
- modules/pact/README.md
- modules/pier/dispatch.go
- modules/pier/prm.go
- modules/pier/stateless.go
- modules/pier/mcp_seam_test.go
- modules/pier/README.md
- modules/wristband/authorize.go
- modules/wristband/server.go
- modules/wristband/consent.go
- modules/wristband/mcp_scopes_smoke_test.go
- modules/wristband/README.md
- docs/services/mcp.md
- docs/services/oauth-server.md
- docs/services/authentication.md
autonomous: true
requirements: []
estimate:
tokens: 52000
raw_tokens: 52000
tasks: 3
confidence: low
must_haves:
truths:
- "SC-1: A wristband MCP server whose Options.ScopesSupported is empty advertises and accepts read, write, ai, offline_access and backend. A non-empty list is used as given, so DefaultOptions still refuses backend. OIDC discovery scopes stay openid, profile, email (D-02)."
- "SC-1: pier protected-resource metadata copies pact.MCPAdvertisedScopes from the published verifier when that list is non-empty, and otherwise keeps PRMScopes (D-04). Pier does not read the raw member-token catalog and does not change the read/write ceiling (D-01)."
- "SC-2: Every in-process MCP tool call carries pact.MCPCall (Subject, SessionID, Tool) set immediately before bouncer.WithDispatch. MCPCallFrom on a context without that value returns ok false (D-09, D-10, D-11)."
- "PendingRequestView.OfflineAccess is filled from the pending row. IssueCode's signature is unchanged."
- "pact, pier and wristband READMEs and docs/services/mcp.md, oauth-server.md and authentication.md name only identifiers this plan adds, and never a consuming application."
artifacts:
- path: modules/pact/mcp.go
provides: "MemberTokenScopeCatalog, MCPAdvertisedScopes, MCPCall, WithMCPCall, MCPCallFrom"
contains: "MemberTokenScopes"
- path: modules/pact/capabilities.go
provides: "FrontendTokenScope.Implies"
contains: "Implies"
- path: modules/pier/dispatch.go
provides: "MCPCall set beside WithDispatch"
contains: "WithMCPCall"
- path: modules/pier/prm.go
provides: "PRM scopes from the verifier or PRMScopes"
contains: "MCPAdvertisedScopes"
- path: modules/wristband/consent.go
provides: "PendingRequestView.OfflineAccess"
contains: "OfflineAccess"
key_links:
- from: modules/wristband/server.go Metadata
to: modules/wristband/authorize.go allowedAuthorizeScopes
via: one resolved scope slice
pattern: resolvedScopes
- from: modules/pier/prm.go
to: pact.MCPAdvertisedScopes
via: app.Lookup of MCPTokenVerifier then a type assert
pattern: MCPScopesSupported
- from: modules/pier/dispatch.go
to: pact.MCPCallFrom
via: WithMCPCall immediately before WithDispatch
pattern: WithMCPCall
---
<objective>
Framework seam for SC-1 and SC-2 (D-01, D-02, D-03, D-04, D-05, D-09, D-10, D-11). Publish the catalog and advertised-scope interfaces, carry an MCP call on context, and make the wristband MCP metadata and authorize allow-list share one resolved scope list so an empty Options.ScopesSupported cannot fall back to the four-name map and drop backend.
Purpose: sm-user-plugin and sm-oauthserver-plugin need these types before they can advertise declared scopes or record who called a tool. The read/write ceiling and the OIDC scope list stay as they are.
Output: pact, pier and wristband API plus the three docs pages and module READMEs. One tracer smoke. The broad unit-test pass is plan 05.
spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
Discretion recorded here, not left open:
- Catalog interface is pact.MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope. The method is not named FrontendTokenScopes, because HasFrontendTokenScopes already has that method (D-05).
- Advertised list is a second interface pact.MCPAdvertisedScopes with MCPScopesSupported() []string. Do not add a method to pact.MCPTokenVerifier.
- pact.MCPCall fields are Subject pact.MCPSubject, SessionID string, Tool string. Setter is pact.WithMCPCall. Accessor is pact.MCPCallFrom (D-10).
- MCP flavour only: empty Options.ScopesSupported resolves to read, write, ai, offline_access, backend. Metadata and allowedAuthorizeScopes both use that slice. DefaultOptions stays the non-empty four-name list and still refuses backend. TestMCPScopesSmoke's empty-slice assertion is updated in the same commit. Do not edit modules/wristband/oidc_options.go.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
@modules/pact/mcp.go
@modules/pact/capabilities.go
@modules/pier/dispatch.go
@modules/pier/prm.go
@modules/pier/stateless.go
@modules/pier/session.go
@modules/wristband/authorize.go
@modules/wristband/server.go
@modules/wristband/consent.go
@modules/wristband/mcp_scopes_smoke_test.go
@modules/bouncer/dispatch.go
</context>
<tasks>
<task type="tracer">
<name>Tracer: one advertised scope list and one MCP call on the dispatch context</name>
<reversibility rating="costly">D-03 and D-10 publish a scope list and a pact.MCPCall shape that later grants and callers will store and read.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/mcp.go, modules/pact/mcp_call_test.go, modules/wristband/authorize.go, modules/wristband/server.go, modules/wristband/mcp_scopes_smoke_test.go, modules/pier/dispatch.go, modules/pier/prm.go, modules/pier/stateless.go, modules/pier/mcp_seam_test.go</files>
<read_first>
modules/pact/capabilities.go (FrontendTokenScope at the struct that today has Name, Label, Description only)
modules/pact/mcp.go (MCPSubject, MCPTokenVerifier, MCPGrant)
modules/bouncer/dispatch.go (WithDispatch context key; do not copy its nil-to-background behaviour)
modules/wristband/authorize.go (allowedAuthorizeScopes empty fallback)
modules/wristband/server.go (Metadata copies opts.ScopesSupported; flavourMCP versus flavourOIDC)
modules/wristband/mcp_scopes_smoke_test.go (empty slice currently expects four names and no backend)
modules/pier/prm.go (PRMScopes and mountPRM)
modules/pier/dispatch.go (dispatcher.run sets bouncer.WithDispatch at the credential assignment)
modules/pier/session.go (toolHandler copies req.Session.ID into callInfo.session; ephemeralSession is the audit word)
modules/pier/stateless.go (statelessPath.ServeHTTP)
modules/pier/tracer_smoke_test.go (package pier_test mount helper to copy, not to rewrite)
modules/pier/filter.go (read only: id.writes ceiling stays)
</read_first>
<action>
Add the pact surface and wire one path through wristband and pier. Do not add a method to pact.MCPTokenVerifier. Do not edit modules/pier/filter.go or modules/wristband/oidc_options.go (D-01, D-02).
In modules/pact/capabilities.go add Implies string on FrontendTokenScope. Zero value means not implied. Update the comment that says the struct carries no permission patterns so it still says the route middleware is what enforces a member scope, and that Implies is only "", "read" or "write" for a later OAuth MCP check. Do not implement that check in this repo (D-01a is plan 02).
In modules/pact/mcp.go add MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope, and MCPAdvertisedScopes with MCPScopesSupported() []string (D-05, D-04). Add MCPCall{Subject MCPSubject, SessionID string, Tool string}, WithMCPCall(ctx, call) context.Context and MCPCallFrom(ctx) (MCPCall, bool) (D-09, D-10). Use an unexported context key. If ctx is nil, WithMCPCall must not invent context.Background; the dispatch context is never nil. MCPCallFrom returns ok false when the value is absent, including a nil context (D-11). Leave MCPSubject's fields unchanged.
In wristband, add an unexported resolvedScopes on Server used by both Metadata's scopes_supported and allowedAuthorizeScopes. Non-empty Options.ScopesSupported is cloned as-is. Empty or nil, on the MCP flavour only (NewServer, flavour zero value, not flavourOIDC), resolves to exactly read, write, ai, offline_access, backend in that order. That is the fix for the four-name fallback dropping backend. Update TestMCPScopesSmoke so the empty-slice case expects those five names and accepts backend, and keep the DefaultOptions case that still refuses backend because that list is non-empty and has no backend. Do not change PKCE, client lookup, or redirect matching (Authorize still validates those before the /connect redirect).
In pier mountPRM, keep the issuer lookup. If the looked-up pact.MCPTokenVerifier is non-nil and also implements pact.MCPAdvertisedScopes and MCPScopesSupported() is non-empty, ScopesSupported is a clone of that slice. Otherwise keep a clone of PRMScopes (D-04). Do not import an OAuth plugin and do not read MemberTokenScopeCatalog here.
In dispatcher.run, immediately before the existing bouncer.WithDispatch line, set callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name}) where subject is the id.subjectFor(t.guardName) value already computed (D-11). sessionID is info.session, except the audit word ephemeralSession becomes "". Add a stateless bool on callInfo. statelessPath.ServeHTTP sets it true on the bearer token-info extraCall value, writing the struct back, before the handler runs. toolHandler copies req.Session.ID() into info.session only when that flag is false and the id is non-empty. Do not expand scopes in pier. Do not put a raw bearer or a code in a log line.
Tests, same commit: modules/pact/mcp_call_test.go TestMCPCallRoundTrip proves WithMCPCall then MCPCallFrom returns the same Subject.Guard, SessionID and Tool, and MCPCallFrom(context.Background()) is ok false. modules/pier/mcp_seam_test.go TestMCPSeam, package pier_test, does three things in one test: wristband.NewServer with nil ScopesSupported answers metadata scopes_supported equal to the five fixed names including backend; a server whose ScopesSupported is those five plus acme:read advertises that exact slice; a pier mounted with a fake MCPTokenVerifier that also implements MCPAdvertisedScopes advertises that slice on the protected-resource document, and one tools/call of a frontend tool reaches a handler where pact.MCPCallFrom is ok, Tool is the tool name, and bouncer dispatch is still set. A verifier that does not implement MCPAdvertisedScopes still yields PRMScopes. Commit in this repo only, staging the files listed on this task. Do not git add -A.
</action>
<verify>
<automated>go test -short -count=1 -run 'TestMCPCallRoundTrip|TestMCPSeam|TestMCPScopesSmoke|TestDiscoveryFields' ./modules/pact ./modules/pier ./modules/wristband</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPCallRoundTrip or TestMCPSeam has no PASS line</fails_when>
</verify>
<acceptance_criteria>
- MCPTokenVerifier still has only MCPVerifyToken and MCPResource.
- Empty MCP Options.ScopesSupported metadata and allow-list both include backend. DefaultOptions still refuses backend.
- TestDiscoveryFields still expects scopes_supported openid, profile, email on the default OIDC document.
- TestMCPSeam PASS shows a declared name on PRM only when the verifier advertises it, and one tool handler observes MCPCall.
- filter.go and oidc_options.go have no diff in this commit.
</acceptance_criteria>
<done>One MCP tool call carries MCPCall, and an empty MCP scope list no longer drops backend. The commit is only the files of this task.</done>
</task>
<task type="auto">
<name>Expose offline_access on the pending consent view</name>
<reversibility rating="reversible">OfflineAccess is an additive field on a view struct. IssueCode's signature stays.</reversibility>
<files>modules/wristband/consent.go</files>
<read_first>
modules/wristband/consent.go (PendingRequestView and IssueCode)
modules/wristband/authorize.go (offline_access is peeled onto the pending row, not stored in Scopes)
</read_first>
<action>
Add OfflineAccess bool to PendingRequestView and set it in PendingRequest from the pending row's existing OfflineAccess flag. Do not change the IssueCode signature, MarkIssued's wristband parameters, or the redirect URL. Callers that ignore the new field stay valid. Do not treat offline_access as a scope inside wristband. Do not log codes.
</action>
<verify>
<automated>go test -short -count=1 ./modules/wristband</automated>
<fails_when>non-zero exit, or the output contains FAIL</fails_when>
</verify>
<acceptance_criteria>
- PendingRequestView has an exported OfflineAccess field set from the pending row.
- IssueCode is still func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error).
</acceptance_criteria>
<done>Consent show can see the offline_access column without a new IssueCode argument.</done>
</task>
<task type="auto">
<name>Document the seam in READMEs and docs</name>
<reversibility rating="reversible">Docs follow the API added in this plan and can be rewritten with it.</reversibility>
<files>modules/pact/README.md, modules/pier/README.md, modules/wristband/README.md, docs/services/mcp.md, docs/services/oauth-server.md, docs/services/authentication.md</files>
<read_first>
modules/pact/README.md
modules/pier/README.md
modules/wristband/README.md
docs/services/mcp.md
docs/services/oauth-server.md
docs/services/authentication.md
modules/pact/mcp.go
modules/pier/prm.go
</read_first>
<action>
Document only identifiers this plan added: MemberTokenScopeCatalog, MemberTokenScopes, MCPAdvertisedScopes, MCPScopesSupported, MCPCall, WithMCPCall, MCPCallFrom, FrontendTokenScope.Implies, PendingRequestView.OfflineAccess, and the existing PRMScopes fallback. State the D-03 rule in pier README, docs/services/mcp.md and docs/services/oauth-server.md: fixed names read, write, ai, offline_access, backend are always on the MCP advertised set; an unset host filter means every declared member scope; a set filter means only those declared names; an empty filter means the five fixed names only. Name the host config key golem15.oauthserver.mcp_scopes as the plugin key plan 03 adds. Do not document it as a framework config key. State that pier copies MCPScopesSupported when non-empty and otherwise PRMScopes (D-04). State that Subject.TokenID is a member personal-token id only when ClientID is empty, and is the OAuth access-token row otherwise, and that Subject.Scopes is the grant, not an implied expansion (D-10, D-11). State that the read/write ceiling is unchanged (D-01) and that OIDC discovery stays openid, profile, email (D-02). State that an empty MCP Options.ScopesSupported resolves to the five fixed names, while a non-empty list is used as given. Framework docs and READMEs must not name a consuming application. Config keys are not checked by the docs checker; the prose must still match the key spelling above. Commit in this repo, staging only these doc files plus any code file this task had to touch (it should touch none). Do not git add -A.
</action>
<verify>
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./modules/pact ./modules/pier ./modules/wristband && go test -short -count=1 ./modules/pact ./modules/pier ./modules/wristband</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
</verify>
<acceptance_criteria>
- TestDocsTree and docs:build --check pass.
- The three READMEs and three docs pages mention MCPCallFrom and MCPAdvertisedScopes and do not mention a consuming application by name.
- go test -short on pact, pier and wristband passes.
</acceptance_criteria>
<done>SC-4 docs for the framework seam are in the same release as the API they name.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP client to wristband authorize | Untrusted scope string and redirect cross here. This plan only changes which names are legal. |
| pier dispatch to route handler | The handler receives MCPCall built from the already-resolved subject, not from tool arguments. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32.1-01 | Elevation of privilege | wristband resolvedScopes | high | mitigate | Empty MCP Options.ScopesSupported resolves to the five fixed names including backend. A non-empty list is not widened. OIDC discovery is untouched. |
| T-32.1-02 | Elevation of privilege | pier filter.go read/write ceiling | high | mitigate | Do not edit Identity.writes or the ceiling in filter.go (D-01). Implies is data on the catalog only; pier does not expand scopes. |
| T-32.1-03 | Spoofing | wristband Authorize redirect | high | mitigate | Do not edit client lookup, exact redirect match, PKCE or the /connect redirect. Scope resolution runs after those checks. |
| T-32.1-04 | Information disclosure | pact.MCPCall.Subject.TokenID | medium | mitigate | README states TokenID is a user_api_tokens id only when ClientID is empty. Dispatch logs keep the existing audit line and must not gain the raw bearer or an authorization code. |
</threat_model>
<verification>
go test -short on pact, pier and wristband passes, TestDiscoveryFields still locks the OIDC scope list, and TestDocsTree passes after the README commit.
</verification>
<success_criteria>
- SC-1 and SC-2 have a framework path: one advertised slice drives MCP metadata and authorize, pier PRM reads it from the verifier, and one tool call exposes MCPCall.
- D-01 ceiling and D-02 OIDC list are unchanged files.
- Docs name the new identifiers and the mcp_scopes rule.
</success_criteria>
<output>
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md` when done.
</output>
## Artifacts this phase produces
- pact.MemberTokenScopeCatalog, method MemberTokenScopes
- pact.MCPAdvertisedScopes, method MCPScopesSupported
- pact.MCPCall, pact.WithMCPCall, pact.MCPCallFrom
- FrontendTokenScope.Implies
- wristband Server resolvedScopes (unexported), used by Metadata and allowedAuthorizeScopes
- PendingRequestView.OfflineAccess
- callInfo.stateless (unexported) so MCPCall.SessionID is empty on the stateless path and never the audit word ephemeral

View File

@@ -0,0 +1,191 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
plan: 01
subsystem: auth
tags: [mcp, oauth, pact, pier, wristband, scopes]
requires:
- phase: 19.7-generic-mcp-module
provides: pier in-process dispatch, PRMScopes and the read/write ceiling
- phase: 19.4-oidc-provider-on-wristband
provides: OIDC discovery scope list left unchanged
provides:
- "pact.MemberTokenScopeCatalog, pact.MCPAdvertisedScopes, pact.MCPCall, pact.WithMCPCall, pact.MCPCallFrom"
- "pact.FrontendTokenScope.Implies"
- "wristband resolvedScopes shared by MCP metadata and authorize"
- "wristband.PendingRequestView.OfflineAccess"
- "pier PRM copies MCPScopesSupported when non-empty, else PRMScopes, and sets MCPCall beside WithDispatch"
affects: [32.1-02, 32.1-03, 32.1-04, 32.1-05]
actuals:
tokens: 17695
tasks: 3
commits: 3
plan_head_before: 2cea3127cc2a2da5f8e0ea6ec47356bfaecc5c67
plan_head_after: 876f0f09f2c4a42864941118d12d636123af1594
tech-stack:
added: []
patterns:
- "MCPAdvertisedScopes is a second interface; MCPTokenVerifier stays MCPVerifyToken and MCPResource"
- "One resolvedScopes slice drives MCP metadata and the authorize allow-list"
- "WithMCPCall is set on the dispatch context immediately before WithDispatch; a nil context is not replaced"
key-files:
created:
- modules/pact/mcp_call_test.go
- modules/pier/mcp_seam_test.go
modified:
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/pact/README.md
- modules/pier/dispatch.go
- modules/pier/prm.go
- modules/pier/session.go
- modules/pier/stateless.go
- modules/pier/README.md
- modules/wristband/authorize.go
- modules/wristband/server.go
- modules/wristband/consent.go
- modules/wristband/mcp_scopes_smoke_test.go
- modules/wristband/README.md
- docs/services/mcp.md
- docs/services/oauth-server.md
- docs/services/authentication.md
key-decisions:
- "Empty MCP Options.ScopesSupported resolves to read, write, ai, offline_access, backend; a non-empty list, including DefaultOptions, is used as given"
- "Advertised scopes are pact.MCPAdvertisedScopes, not a new method on pact.MCPTokenVerifier"
- "Stateless tool calls set callInfo.stateless so MCPCall.SessionID stays empty"
patterns-established:
- "Pattern: Metadata and allowedAuthorizeScopes both call Server.resolvedScopes"
- "Pattern: pier mountPRM type-asserts MCPAdvertisedScopes and clones a non-empty MCPScopesSupported, else PRMScopes"
requirements-completed: []
coverage:
- id: D1
description: "An empty MCP Options.ScopesSupported advertises and accepts the five fixed names including backend; DefaultOptions still refuses backend; OIDC discovery stays openid, profile, email"
verification:
- kind: unit
ref: modules/wristband/mcp_scopes_smoke_test.go#TestMCPScopesSmoke
status: pass
- kind: unit
ref: modules/wristband/oidc_discovery_test.go#TestDiscoveryFields
status: pass
human_judgment: false
- id: D2
description: "pier copies MCPScopesSupported onto protected-resource metadata when non-empty and otherwise keeps PRMScopes, and one frontend tools/call observes MCPCall beside the bouncer dispatch marker"
verification:
- kind: unit
ref: modules/pier/mcp_seam_test.go#TestMCPSeam
status: pass
human_judgment: false
- id: D3
description: "WithMCPCall then MCPCallFrom round-trips Subject.Guard, SessionID and Tool; a context without a call answers ok false"
verification:
- kind: unit
ref: modules/pact/mcp_call_test.go#TestMCPCallRoundTrip
status: pass
human_judgment: false
- id: D4
description: "PendingRequestView.OfflineAccess is set from the pending row; IssueCode's signature is unchanged"
verification:
- kind: unit
ref: go test -short ./modules/wristband
status: pass
human_judgment: true
rationale: "No test asserts that PendingRequest copies OfflineAccess; the package suite only proves the field compiles and existing consent tests still pass"
- id: D5
description: "pact, pier and wristband READMEs and the three service docs name the new identifiers and the mcp_scopes rule without naming a consuming application"
verification:
- kind: other
ref: go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check
status: pass
human_judgment: false
duration: 12min
completed: 2026-10-28
status: complete
---
# Phase 32.1 Plan 01: MCP scope list and call context Summary
**One resolved MCP scope list drives wristband metadata and authorize, pier copies it from the verifier, and every in-process tool call carries pact.MCPCall**
## Performance
- **Duration:** 12 min
- **Started:** 2026-10-28T13:25:44Z
- **Completed:** 2026-10-28T13:37:30Z
- **Tasks:** 3
- **Files modified:** 18
## Accomplishments
- `pact` publishes `MemberTokenScopeCatalog`, `MCPAdvertisedScopes`, `MCPCall` (`WithMCPCall` / `MCPCallFrom`) and `FrontendTokenScope.Implies`. `MCPTokenVerifier` is still only `MCPVerifyToken` and `MCPResource`.
- An empty or nil MCP `Options.ScopesSupported` resolves to `read`, `write`, `ai`, `offline_access`, `backend` for both metadata and the authorize allow-list. `DefaultOptions` stays the four-name list and still refuses `backend`. OIDC discovery stays `openid`, `profile`, `email`.
- pier copies `MCPScopesSupported` into protected-resource metadata when that slice is non-empty, otherwise `PRMScopes`. `dispatcher.run` stores `MCPCall` immediately before `bouncer.WithDispatch`. Stateless calls leave `SessionID` empty.
- `PendingRequestView.OfflineAccess` is the pending row's flag. `IssueCode`'s signature is unchanged.
## Task Commits
Each task was committed atomically:
1. **Task 1: Tracer — one advertised scope list and one MCP call on the dispatch context** - `63d05e8` (feat)
2. **Task 2: Expose offline_access on the pending consent view** - `9e29d6f` (feat)
3. **Task 3: Document the seam in READMEs and docs** - `876f0f0` (docs)
## Files Created/Modified
- `modules/pact/mcp.go` - catalog and advertised-scope interfaces, `MCPCall`, `WithMCPCall`, `MCPCallFrom`
- `modules/pact/capabilities.go` - `FrontendTokenScope.Implies`
- `modules/pact/mcp_call_test.go` - round-trip and absent-context cases
- `modules/wristband/server.go` - unexported `resolvedScopes` used by metadata
- `modules/wristband/authorize.go` - allow-list uses that same slice
- `modules/wristband/consent.go` - `PendingRequestView.OfflineAccess`
- `modules/wristband/mcp_scopes_smoke_test.go` - empty list expects the five names and accepts backend
- `modules/pier/prm.go` - copy `MCPScopesSupported` or keep `PRMScopes`
- `modules/pier/dispatch.go` - `WithMCPCall` immediately before `WithDispatch`; blank the ephemeral audit word
- `modules/pier/session.go` - copy the SDK session id only when the call is not stateless
- `modules/pier/stateless.go` - mark `callInfo.stateless` before the handler runs
- `modules/pier/mcp_seam_test.go` - metadata, PRM and one tool call that observes `MCPCall`
- `modules/pact/README.md`, `modules/pier/README.md`, `modules/wristband/README.md` - the new identifiers and the advertised-set rule
- `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md` - the same rule, `MCPCallFrom`, and the unchanged ceiling and OIDC list
## Decisions Made
None beyond the plan's recorded discretion. `MemberTokenScopeCatalog.MemberTokenScopes` is not named `FrontendTokenScopes`. `MCPAdvertisedScopes` is a separate interface. Empty MCP options resolve to the five fixed names; a non-empty list is cloned as given. `filter.go` and `oidc_options.go` were not edited.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
The framework seam for SC-1 and SC-2 is in place. Later plans in this phase can publish the member-token catalog, build the advertised list from `golem15.oauthserver.mcp_scopes`, and read `MCPCallFrom` on a dispatched route. The read/write ceiling and the OIDC discovery list are unchanged.
## Self-Check: PASSED
- FOUND: modules/pact/mcp.go
- FOUND: modules/pact/mcp_call_test.go
- FOUND: modules/pier/mcp_seam_test.go
- FOUND: modules/wristband/consent.go
- FOUND: modules/pact/README.md
- FOUND: docs/services/mcp.md
- FOUND: 63d05e8
- FOUND: 9e29d6f
- FOUND: 876f0f0
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
*Completed: 2026-10-28*

View File

@@ -0,0 +1,173 @@
---
phase: 32.1
plan: 02
type: execute
wave: 2
depends_on:
- 32.1-01
files_modified:
- ../sm-user-plugin/classes/api_token_scopes.go
- ../sm-user-plugin/classes/mcp_guard.go
- ../sm-user-plugin/classes/mcp_guard_test.go
- ../sm-user-plugin/plugin.go
- ../sm-user-plugin/README.md
- ../sm-user-plugin/go.mod
autonomous: true
requirements: []
estimate:
tokens: 38000
raw_tokens: 38000
tasks: 2
confidence: low
must_haves:
truths:
- "SC-1: On a branch from tag v0.1.14, an OAuth MCP subject (ClientID non-empty) whose grant holds read satisfies a declared scope with Implies read, in both MCPAllows and the credential MCPDispatch builds (D-01a). write does not satisfy Implies read."
- "A personal-token subject (ClientID empty) is not expanded. Its credential ID stays subject.TokenID, the v0.1.14 rule (D-11)."
- "The compiled catalog is published as pact.MemberTokenScopeCatalog. Implied names are not written onto the token row, the fingerprint, or subject.Scopes (D-05, D-01a)."
- "The app submodule at sm-demo-app/plugins/golem15/user is not edited."
artifacts:
- path: ../sm-user-plugin/classes/mcp_guard.go
provides: "OAuth-only Implies expansion shared by MCPAllows and MCPDispatch"
contains: "ClientID"
- path: ../sm-user-plugin/plugin.go
provides: "Publish of MemberTokenScopeCatalog beside the concrete catalog"
contains: "MemberTokenScopeCatalog"
key_links:
- from: classes/mcp_guard.go MCPAllows
to: classes/mcp_guard.go MCPDispatch
via: one helper that clones scopes only when ClientID is non-empty
pattern: ClientID
- from: plugin.go bootAPITokenScopes
to: pact.MemberTokenScopeCatalog
via: app.Publish of the interface
pattern: MemberTokenScopeCatalog
---
<objective>
sm-user-plugin from tag v0.1.14 publishes the member-token catalog and applies Implies only to OAuth MCP subjects (D-01a, D-05, D-11). Personal tokens keep their scopes and their credential id.
Purpose: an OAuth grant of read and write can list and run the same tools as a personal token that carries the declared names the plugins marked as implied, without storing those names on the grant.
Output: a worktree at the path below, on a branch from v0.1.14, plus one tracer test. The rest of the Implies matrix is plan 05.
Worktree, exact: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin
Remote: git@git.golem15.com:golem15/sm-user-plugin.git
Branch: phase-32.1-mcp-implied-scopes, created from tag v0.1.14 (commit a1b78b80). Do not use sm-demo-app/plugins/golem15/user or any other app submodule. That checkout is v0.1.13 and has the wrong credential-id body.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
@modules/pact/capabilities.go
@modules/pact/mcp.go
</context>
<tasks>
<task type="tracer">
<name>Tracer: v0.1.14 worktree publishes the catalog and expands one implied scope</name>
<precondition>git ls-remote git@git.golem15.com:golem15/sm-user-plugin.git refs/tags/v0.1.14 prints a commit, and /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin is either absent or already this phase's worktree on phase-32.1-mcp-implied-scopes. Halt if that path is any other checkout, including an app submodule.</precondition>
<reversibility rating="costly">D-01a: once a plugin marks Implies read or write, live OAuth agents gain those tools. Removing it later shrinks their tool list.</reversibility>
<files>../sm-user-plugin/go.mod, ../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/plugin.go, ../sm-user-plugin/classes/mcp_guard_test.go</files>
<read_first>
The worktree's classes/mcp_guard.go after the branch exists (v0.1.14 MCPDispatch sets credential.ID only when ClientID is empty). Do not read the v0.1.13 submodule as the body to edit.
The worktree's classes/api_token_scopes.go CompileAPITokenScopes and builtInAPITokenScopeKeys.
The worktree's plugin.go bootAPITokenScopes (today app.Publish of the concrete catalog and of pact.MCPPersonalTokenResolver).
modules/pact/mcp.go MemberTokenScopeCatalog in the framework repo.
../sm-oauthserver-plugin/go.mod replace line, as the pattern for a sibling replace.
</read_first>
<action>
Fetch the tag and create the worktree with git clone or git worktree add of git@git.golem15.com:golem15/sm-user-plugin.git at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, branch phase-32.1-mcp-implied-scopes from v0.1.14. Confirm HEAD's MCPDispatch comment says a personal-token credential ID is subject.TokenID and an OAuth credential keeps ID 0. If the comment still says the credential has ID 0 and never sets credential.ID, stop: that is v0.1.13.
Point the summercms replace at ../summercms.go so this worktree compiles against plan 01. If go.mod has no replace for git.golem15.com/golem15/summercms, add one. Commit that replace with the feature commit in the plugin repo. Do not retarget any app submodule.
On *APITokenScopeCatalog add MemberTokenScopes() []pact.FrontendTokenScope returning the compiled list (built-ins and declared). In bootAPITokenScopes, after the existing concrete app.Publish(catalog), also app.Publish the same value as pact.MemberTokenScopeCatalog (D-05). Do not remove the concrete publish or the MCPPersonalTokenResolver publish.
Extend CompileAPITokenScopes: reject Implies other than "", "read" or "write"; reject a declared name backend or offline_access. Built-in read, write and ai keep Implies empty. Existing rejections of read, write, ai as declared names, duplicates, and empty label or description stay.
Add one helper used by MCPAllows and MCPDispatch (D-01a). When subject.ClientID is empty, return subject.Scopes unchanged. Otherwise clone subject.Scopes and, for each catalog entry whose Implies is read or write, if the clone contains that stock scope and not the declared name, append the declared name. Never append a name the catalog does not mark. Never let write satisfy Implies read. Do not write the clone back onto subject.Scopes, a token row, or a fingerprint. MCPAllows checks req.Scopes against the helper's slice. MCPDispatch puts that slice on the in-memory models.APIToken.Scopes and still sets credential.ID = subject.TokenID only when ClientID is empty (D-11). Do not set APIToken.OAuthClientID.
Tracer test TestMCPImpliesOAuth in classes/mcp_guard_test.go: an OAuth subject with ClientID set and scopes read passes MCPAllows for a requirement servers:read when the catalog entry says Implies read, and the MCPDispatch credential HasScope servers:read while its ID stays 0. Commit only in the plugin repo, staging paths under that worktree. Do not git add -A. Do not push or tag.
</action>
<verify>
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run '^TestMCPImpliesOAuth$' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
</verify>
<acceptance_criteria>
- git -C the worktree rev-parse HEAD is a descendant of v0.1.14, not of the v0.1.13 submodule commit 07196a4 alone.
- TestMCPImpliesOAuth PASS: OAuth read grant satisfies Implies read, credential ID is 0.
- sm-demo-app/plugins/golem15/user has no diff from this task.
</acceptance_criteria>
<done>The v0.1.14 branch publishes the catalog and one OAuth implied scope works end to end through allow and dispatch.</done>
</task>
<task type="auto">
<name>Reject bad Implies and keep personal tokens unexpanded</name>
<reversibility rating="costly">D-01a and D-03: boot errors and the personal-token exemption are part of the contract plugins and grants will rely on.</reversibility>
<files>../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/classes/mcp_guard_test.go, ../sm-user-plugin/README.md</files>
<read_first>
The worktree classes/api_token_scopes.go and classes/mcp_guard.go after the tracer commit.
The worktree README.md
modules/pact/capabilities.go FrontendTokenScope.Implies comment
</read_first>
<action>
Finish the helper's edges if the tracer left any call site on slices.Clone(subject.Scopes) for the scope check. MCPAllows and MCPDispatch must both call the helper. A personal token (ClientID empty) with only read must not gain a declared name. A grant of write must not satisfy Implies read. A second copy of an already-present declared name is not appended.
CompileAPITokenScopes error text names the plugin and the bad Implies value or the reserved name backend or offline_access, in the same style as the existing declared-name errors. Phrase-key checks in bootAPITokenScopes stay.
README: document FrontendTokenScope.Implies (empty, read, or write), that expansion happens only inside MCPAllows and MCPDispatch when ClientID is non-empty, that personal tokens are unchanged, and that the catalog is published as pact.MemberTokenScopeCatalog. Do not name a consuming application. Do not persist implied names. Commit in the plugin repo only. Do not push or tag. Do not edit the framework repo in this plan.
</action>
<verify>
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run 'TestMCP' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesOAuth has no PASS line</fails_when>
</verify>
<acceptance_criteria>
- A personal-token test in this package shows scopes unchanged and credential ID equal to TokenID.
- An Implies value other than empty, read, or write fails CompileAPITokenScopes.
- README names MemberTokenScopeCatalog and Implies and names no consuming application.
</acceptance_criteria>
<done>D-01a holds on both call sites, personal tokens are not expanded, and the plugin README matches the API.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| OAuth grant scopes to MCPAllows / user.scope | The grant is the attacker's consented set. Implies may add only names the plugin marked. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32.1-05 | Elevation of privilege | MCPUserGuard Implies helper | high | mitigate | Expand only when ClientID is non-empty, only for a catalog Implies of read or write, only the declared name that says so. Do not write the expanded set onto the token, the fingerprint, or subject.Scopes. Personal tokens are returned unchanged (D-01a, D-11). |
</threat_model>
<verification>
go test -short -run TestMCP in the v0.1.14 worktree passes, including TestMCPImpliesOAuth, and the app submodule is untouched.
</verification>
<success_criteria>
- Catalog publication and OAuth-only Implies are on a branch from v0.1.14.
- Personal-token credential ID behaviour from that tag is intact.
</success_criteria>
<output>
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md` when done.
</output>
## Artifacts this phase produces
- Worktree /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin branch phase-32.1-mcp-implied-scopes from tag v0.1.14
- *APITokenScopeCatalog.MemberTokenScopes
- app.Publish of pact.MemberTokenScopeCatalog
- CompileAPITokenScopes rejection of bad Implies and of declared names backend and offline_access
- Unexported effective-scope helper in classes/mcp_guard.go
- TestMCPImpliesOAuth

View File

@@ -0,0 +1,194 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
plan: 02
subsystem: auth
tags: [mcp, oauth, scopes, implies, sm-user-plugin]
requires:
- phase: 32.1-01
provides: pact.MemberTokenScopeCatalog and FrontendTokenScope.Implies
provides:
- "sm-user-plugin on phase-32.1-mcp-implied-scopes publishes the compiled catalog as pact.MemberTokenScopeCatalog"
- "OAuth MCP subjects expand declared scopes whose Implies is read or write inside MCPAllows and MCPDispatch"
- "Personal tokens are not expanded and keep credential ID equal to TokenID"
- "CompileAPITokenScopes rejects a bad Implies and the reserved names backend and offline_access"
affects: [32.1-03, 32.1-05]
actuals:
tokens: 10492
tasks: 2
commits: 0
plan_head_before: 415a09333bf9bb2cc93ac8324aac794cc74ff818
plan_head_after: 415a09333bf9bb2cc93ac8324aac794cc74ff818
tech-stack:
added: []
patterns:
- "One unexported helper, mcpEffectiveScopes, feeds MCPAllows and MCPDispatch"
- "Expansion clones the grant only when ClientID is non-empty and never writes that clone back"
- "The catalog is published twice: the concrete *APITokenScopeCatalog and pact.MemberTokenScopeCatalog"
key-files:
created:
- ../sm-user-plugin/mcp_implies_test.go
modified:
- ../sm-user-plugin/classes/api_token_scopes.go
- ../sm-user-plugin/classes/mcp_guard.go
- ../sm-user-plugin/plugin.go
- ../sm-user-plugin/go.mod
- ../sm-user-plugin/README.md
key-decisions:
- "Implies tests live in the plugin root package so go test . runs them; classes/mcp_guard_test.go would report no tests to run"
- "The tracer loads a principal from an in-memory database/sql driver so go test -short proves MCPAllows and MCPDispatch without Docker"
- "The phase branch replace points at ../summercms.go, the sibling of this checkout, not the old submodule-relative path"
patterns-established:
- "Pattern: OAuth effective scopes are a clone plus catalog names whose Implies is exactly the stock scope the grant holds"
- "Pattern: a personal token returns subject.Scopes unchanged, including slice identity, and credential.ID stays TokenID"
requirements-completed: []
coverage:
- id: D1
description: "An OAuth subject with ClientID set and scopes read passes MCPAllows for servers:read when Implies is read, and the MCPDispatch credential HasScope servers:read with ID 0. The grant slice is not rewritten. The catalog is published as pact.MemberTokenScopeCatalog."
verification:
- kind: unit
ref: mcp_implies_test.go#TestMCPImpliesOAuth
status: pass
human_judgment: false
- id: D2
description: "A personal token (ClientID empty) with only read does not gain servers:read, and the dispatch credential ID equals TokenID"
verification:
- kind: unit
ref: mcp_implies_test.go#TestMCPImpliesPersonalUnchanged
status: pass
human_judgment: false
- id: D3
description: "A grant of write does not satisfy Implies read, and a declared name already on the grant is not appended a second time. A catalog entry with empty Implies is not appended."
verification:
- kind: unit
ref: mcp_implies_test.go#TestMCPImpliesWriteDoesNotGrantRead
status: pass
- kind: unit
ref: mcp_implies_test.go#TestMCPImpliesAlreadyPresent
status: pass
human_judgment: false
- id: D4
description: "CompileAPITokenScopes fails for Implies other than empty, read, or write, and for declared names backend and offline_access. The error names the plugin and the bad value."
verification:
- kind: unit
ref: mcp_implies_test.go#TestMCPImpliesCompileRejects
status: pass
human_judgment: false
- id: D5
description: "The plugin README names FrontendTokenScope.Implies, the OAuth-only expansion, unchanged personal tokens, and pact.MemberTokenScopeCatalog, and names no consuming application"
verification:
- kind: other
ref: README.md MemberTokenScopeCatalog and Implies; rg found no consuming-application name
status: pass
human_judgment: false
duration: 17min
completed: 2026-10-28
status: complete
---
# Phase 32.1 Plan 02: OAuth implied scopes Summary
**On a branch from sm-user-plugin v0.1.14, an OAuth MCP grant of read satisfies declared scopes marked Implies read, and a personal token keeps its scopes and credential id**
## Performance
- **Duration:** 17 min
- **Started:** 2026-10-28T13:48:50Z
- **Completed:** 2026-10-28T14:06:00Z
- **Tasks:** 2
- **Files modified:** 6
## Accomplishments
- The checkout `/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin` is branch `phase-32.1-mcp-implied-scopes` from tag `v0.1.14` (`a1b78b8`). `MCPDispatch` still sets `credential.ID` only when `ClientID` is empty.
- `*APITokenScopeCatalog.MemberTokenScopes` returns the compiled list. Boot publishes that value as `pact.MemberTokenScopeCatalog` beside the concrete catalog and still publishes `pact.MCPPersonalTokenResolver`.
- `mcpEffectiveScopes` is the only expansion. It returns `subject.Scopes` unchanged when `ClientID` is empty. Otherwise it clones the grant and appends a catalog name only when `Implies` is `read` or `write`, the clone holds that stock scope, and the name is not already present. `MCPAllows` and `MCPDispatch` both use it. The clone is not written onto `subject.Scopes`, a token row, or a fingerprint. `APIToken.OAuthClientID` is not set.
- `CompileAPITokenScopes` rejects `Implies` other than empty, `read`, or `write`, and rejects declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. The summercms replace on this branch is `../summercms.go`.
## Task Commits
Plugin repo `sm-user-plugin` only. summercms.go had no code commits in this plan (`commits: 0` from `415a093..HEAD` at summary time).
1. **Task 1: Tracer — v0.1.14 worktree publishes the catalog and expands one implied scope** - `25fd981` (feat)
2. **Task 2: Reject bad Implies and keep personal tokens unexpanded** - `193a381` (test)
## Files Created/Modified
- `../sm-user-plugin/classes/api_token_scopes.go` - `MemberTokenScopes`, `Implies` on the compiled scope, rejection of bad `Implies` and of `backend` / `offline_access`
- `../sm-user-plugin/classes/mcp_guard.go` - `mcpEffectiveScopes` shared by `MCPAllows` and `MCPDispatch`
- `../sm-user-plugin/plugin.go` - second publish as `pact.MemberTokenScopeCatalog`
- `../sm-user-plugin/go.mod` - replace `../summercms.go` so the branch compiles against plan 01
- `../sm-user-plugin/mcp_implies_test.go` - OAuth, personal-token, write, duplicate, and compile-rejection tests
- `../sm-user-plugin/README.md` - `Implies`, the OAuth-only expansion, and `pact.MemberTokenScopeCatalog`
## Decisions Made
- The tests are in the plugin root package (`mcp_implies_test.go`), not `classes/mcp_guard_test.go`. The plan's verify is `go test .`, which does not compile `classes/`.
- `MCPAllows` and `MCPDispatch` need a principal before they can return success. Docker-backed tests skip under `-short` (CLAUDE.md), and the verify command is `go test -short`, so the tests answer one user and no group grants from an in-memory `database/sql` driver. No new module dependency.
- The existing `go.mod` replace pointed at `../../../../summercms.go` (a submodule layout). This checkout is a sibling of `summercms.go`, so the replace is `../summercms.go`, as the plan required. Do not tag that path as a release for app submodules.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Tests live in the plugin root package**
- **Found during:** Task 1 (tracer)
- **Issue:** The plan named `classes/mcp_guard_test.go`, but `go -C <plugin> test .` runs package `user`. A test under `classes/` makes that command print `[no tests to run]`.
- **Fix:** `TestMCPImpliesOAuth` and the task 2 cases are in `mcp_implies_test.go` at the plugin root.
- **Files modified:** `mcp_implies_test.go`
- **Verification:** `go test -short -count=1 -run 'TestMCP' .` exits 0; `-v` shows `--- PASS: TestMCPImpliesOAuth`
- **Committed in:** `25fd981` (task 1), `193a381` (task 2)
**2. [Rule 3 - Blocking] In-memory principal instead of testcontainers**
- **Found during:** Task 1 (tracer)
- **Issue:** A passing `MCPAllows` / `MCPDispatch` loads the user. The admin harness skips under `-short`, so a Docker test would not produce a PASS on the mandated command. CLAUDE.md says Docker-backed tests skip under `-short`.
- **Fix:** The tests publish a `*gorm.DB` over a tiny in-memory driver that returns one user row and no group grants. Scope expansion does not read the database.
- **Files modified:** `mcp_implies_test.go`
- **Verification:** `TestMCPImpliesOAuth` PASS under `-short` with no container
- **Committed in:** `25fd981`
---
**Total deviations:** 2 auto-fixed (2 blocking)
**Impact on plan:** Both keep the mandated `go test -short` command able to prove the behavior. No production path changed beyond the plan.
## Issues Encountered
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. STATE.md was updated with phase-scoped handlers where they exist, and was not committed.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Plan 03 can `Lookup` `pact.MemberTokenScopeCatalog` from an app that boots this branch. The catalog includes built-ins (empty `Implies`) then declared scopes.
Plan 05 should extend `mcp_implies_test.go` in the plugin root, not add `classes/mcp_guard_test.go`, or its `go test .` verify will not see the new tests. These tests already exist and must not be redeclared in package `user`: `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesAlreadyPresent`, `TestMCPImpliesCompileRejects`. `TestMCPImpliesNotPersisted` is not a separate test yet; `TestMCPImpliesOAuth` already checks that `subject.Scopes` keeps its original slice header.
The app submodule `sm-demo-app/plugins/golem15/user` is still `07196a4` with no diff. This branch was not pushed or tagged. The `../summercms.go` replace is for this sibling checkout only.
## Self-Check: PASSED
- FOUND: ../sm-user-plugin/classes/mcp_guard.go
- FOUND: ../sm-user-plugin/classes/api_token_scopes.go
- FOUND: ../sm-user-plugin/plugin.go
- FOUND: ../sm-user-plugin/mcp_implies_test.go
- FOUND: ../sm-user-plugin/README.md
- FOUND: ../sm-user-plugin/go.mod
- FOUND: 25fd981
- FOUND: 193a381
- Demo submodule HEAD 07196a4, status clean
- `go test -short -count=1 -run 'TestMCP' .` in the plugin: exit 0
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
*Completed: 2026-10-28*

View File

@@ -0,0 +1,303 @@
---
phase: 32.1
plan: 03
type: execute
wave: 3
depends_on:
- 32.1-01
- 32.1-02
files_modified:
- ../sm-oauthserver-plugin/config/config.yaml
- ../sm-oauthserver-plugin/servers.go
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/mcp_store.go
- ../sm-oauthserver-plugin/classes/mcp_scopes.go
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/lang/en/lang.yaml
- ../sm-oauthserver-plugin/lang/pl/lang.yaml
- ../sm-oauthserver-plugin/README.md
- ../sm-oauthserver-plugin/authorizations_smoke_test.go
- ../sm-oauthserver-plugin/mcp_scopes_test.go
- ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
autonomous: false
requirements: []
estimate:
tokens: 64000
raw_tokens: 64000
tasks: 4
confidence: low
must_haves:
truths:
- "SC-1: golem15.oauthserver.mcp_scopes unset advertises every declared member scope; a set list advertises only those names; an empty list advertises the five fixed names. Fixed names read, write, ai, offline_access, backend are always present. An unknown name fails boot (D-03, D-04)."
- "SC-3: An MCP allow upserts golem15_oauthserver_consents for that user and client, replacing scopes. It does not call SaveConsent. A later allow of a smaller set drops the unticked names (D-08, D-12)."
- "SC-3: Revoke takes lockUserGrants, deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens of the pair, then emits MCPSubjectRevoked with ClientID and a zero TokenID (D-12, D-15)."
- "GET /oauth/authorizations entries include kind oidc or mcp as the last key. A POST of decision allow with no scopes field still grants the whole request (D-13, D-06)."
- "offline_access stays a column flag. The pending view surfaces it. IssueCode's signature is unchanged (D-06)."
artifacts:
- path: ../sm-oauthserver-plugin/config/config.yaml
provides: "golem15.oauthserver.mcp_scopes"
contains: "mcp_scopes"
- path: ../sm-oauthserver-plugin/classes/mcp_verifier.go
provides: "MCPScopesSupported on the existing verifier value"
contains: "MCPScopesSupported"
- path: ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
provides: "scope_details, logo_url, consent replace"
contains: "scope_details"
- path: ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
provides: "idempotent MCP consent backfill"
contains: "golem15_oauthserver_consents"
key_links:
- from: servers.go NewServers
to: classes/mcp_verifier.go MCPScopesSupported
via: the same slice assigned to mcpOpts.ScopesSupported
pattern: MCPScopesSupported
- from: controllers/mcp_consent_api.go MCPConsentDecide
to: golem15_oauthserver_consents
via: replace upsert after IssueCode, not SaveConsent
pattern: ON CONFLICT
- from: controllers/authorizations.go revoke
to: golem15_oauthserver_mcp_codes
via: lockUserGrants then Delete of MCPCode
pattern: lockUserGrants
---
<objective>
sm-oauthserver-plugin closes SC-1's host filter and SC-3's consent row (D-02, D-03, D-04, D-06, D-07, D-08, D-12, D-13, D-14, D-15). The MCP server's one scope slice is the fixed five plus declared names filtered by golem15.oauthserver.mcp_scopes. Consent JSON gains scope_details and logo_url. A decision replaces the stored scopes. Revoke deletes MCP codes under the grants lock. kind is additive. The backfill is one-way and waits for the checkpoint in this plan.
Purpose: an agent that consents over MCP can be revoked per client, and the protected-resource list cannot drift from the authorization server.
Output: plugin code, phrases, README, the authorizations key-list update, and, after the checkpoint, the backfill migration. Behaviour proofs beyond the existing smoke tests are plan 05.
Discretion recorded here:
- An opt-in name that no plugin declares fails boot. The error names the key golem15.oauthserver.mcp_scopes and the unknown name. A warning would start a server whose metadata disagrees with the file (D-03).
- kind is always the string models.ClientKindOIDC or models.ClientKindMCP ("oidc" or "mcp"), never omitted, and it is the last JSON key (D-13).
Do not bump go.mod require versions to unreleased tags. The existing replace of summercms to ../summercms.go stays. Do not edit views/consent.html.tmpl. Do not edit wristband. Do not force read into a grant.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
@../sm-oauthserver-plugin/servers.go
@../sm-oauthserver-plugin/classes/mcp_verifier.go
@../sm-oauthserver-plugin/controllers/mcp_consent_api.go
@../sm-oauthserver-plugin/controllers/authorizations.go
@../sm-oauthserver-plugin/classes/oidc_store.go
@../sm-oauthserver-plugin/classes/mcp_store.go
@../sm-oauthserver-plugin/classes/mcp_backend_binding.go
@../sm-oauthserver-plugin/config/config.yaml
@modules/pier/config.go
@modules/pact/mcp.go
</context>
<tasks>
<task type="tracer">
<name>Tracer: one MCP scope slice from config to metadata and the verifier</name>
<precondition>/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/go.mod exists and its replace points at ../summercms.go. Plan 01's pact.MCPAdvertisedScopes is in that tree.</precondition>
<reversibility rating="costly">D-03: scope names published in metadata and later stored on grants are costly to retract.</reversibility>
<files>../sm-oauthserver-plugin/config/config.yaml, ../sm-oauthserver-plugin/servers.go, ../sm-oauthserver-plugin/classes/mcp_scopes.go, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/mcp_scopes_test.go</files>
<read_first>
../sm-oauthserver-plugin/servers.go NewServers (mcpOpts := opts then append backend)
../sm-oauthserver-plugin/config/config.yaml header (SUMMER_ double-underscore env spelling) and the mcp: block
../sm-oauthserver-plugin/classes/mcp_verifier.go MCPTokenVerifier
modules/pier/config.go originsKey (the []any / []string / comma-string switch to copy)
modules/compass config Has versus String (String is empty for both missing and empty)
modules/pact/mcp.go MCPAdvertisedScopes
</read_first>
<action>
Add config key mcp_scopes under the mcp block in config/config.yaml, with a comment that unset means every declared member scope, a YAML list means only those declared names, and an empty list means the five fixed names only. Document the env spelling SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES. Do not change mcp.enabled's default of false.
Add an unexported builder, called from NewServers, that returns the MCP slice and an error. Order is always read, write, ai, offline_access, backend, then declared names in catalog order. Declared names are MemberTokenScopes entries whose name is not read, write or ai. Lookup pact.MemberTokenScopeCatalog. A missing catalog means no declared names. When Config.Has("golem15.oauthserver.mcp_scopes") is false, include every declared name. When it is true, include only declared names the list contains. Read the value with Lookup, accepting []string, []any of strings, or a comma-separated string, the same three shapes originsKey accepts. An unknown name fails boot with an error that contains golem15.oauthserver.mcp_scopes and that name. Do not filter the five fixed names. Never return an empty slice.
Assign that slice only to mcpOpts.ScopesSupported. mcpOpts := opts is already a struct copy. Do not assign cfg.OIDC.ScopesSupported and do not append backend a second time (D-02). Store the same slice on the MCPTokenVerifier value NewServers already builds, and add MCPScopesSupported() []string so that value implements pact.MCPAdvertisedScopes (D-04). Publish stays the existing verifier publish; the extra method is on that concrete value, not a new method of pact.MCPTokenVerifier.
TestMCPAdvertisedScopes in mcp_scopes_test.go, no Docker: a fake catalog with acme:read, key unset, yields the five fixed names plus acme:read; key set to acme:read yields the same; key set to empty yields the five only; key set to not-declared returns an error naming the key; the OIDC server's scopes stay openid, profile, email. Hit Metadata on the MCP server from NewServers for the unset case and compare scopes_supported to MCPScopesSupported. Commit in the plugin repo only, those files. Do not git add -A. Do not push or tag.
</action>
<verify>
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -short -count=1 -run '^TestMCPAdvertisedScopes$' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
</verify>
<acceptance_criteria>
- TestMCPAdvertisedScopes PASS covers unset, set, empty, and unknown name.
- OIDC discovery scopes in that test stay openid, profile, email.
- mcpOpts.ScopesSupported is never empty, including the empty-catalog case.
</acceptance_criteria>
<done>Metadata and the verifier advertise one non-empty slice, and an unknown mcp_scopes name fails boot.</done>
</task>
<task type="auto">
<name>Consent JSON, replace-not-merge, offline_access, revoke, and kind</name>
<precondition>docker info exits 0. The plugin tests use testcontainers Postgres and skip the database when Docker is down.</precondition>
<reversibility rating="reversible">Consent JSON fields and the revoke delete list are code. The backfill that writes rows on every install is the next task, not this one.</reversibility>
<files>../sm-oauthserver-plugin/controllers/mcp_consent_api.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/classes/mcp_store.go, ../sm-oauthserver-plugin/lang/en/lang.yaml, ../sm-oauthserver-plugin/lang/pl/lang.yaml, ../sm-oauthserver-plugin/README.md, ../sm-oauthserver-plugin/authorizations_smoke_test.go</files>
<read_first>
../sm-oauthserver-plugin/controllers/mcp_consent_api.go (show body, grantedScopes, IssueCode call, WithConsentedBackend)
../sm-oauthserver-plugin/classes/oidc_store.go SaveConsent (unions; do not call it) and TouchConsent
../sm-oauthserver-plugin/classes/mcp_store.go MarkIssued (does not write offline_access today)
../sm-oauthserver-plugin/classes/mcp_backend_binding.go WithConsentedBackend and lockUserGrants
../sm-oauthserver-plugin/controllers/authorizations.go revoke and the authorization struct
../sm-oauthserver-plugin/authorizations_smoke_test.go (len of keys is 6)
../sm-oauthserver-plugin/mcp_consent_smoke_test.go the subtest that posts scopes backend and expects 422
../sm-oauthserver-plugin/models/client.go ClientKindOIDC and ClientKindMCP
modules/phrasebook translator Get
</read_first>
<action>
Keep scopes as []string. Add scope_details objects with name, label, description, locked, and add logo_url on the client object (D-07). locked is true only for read when read was requested. logo_url is classes.ClientLogoURL, empty string when none. Labels: if the catalog text contains "::", resolve it with phrasebook.Translator.Get; otherwise use the literal. backend and offline_access are not in the member catalog. Add phrase keys under consent.mcp in lang/en/lang.yaml and lang/pl/lang.yaml. English: backend label "Also act as an administrator", description "The agent may use the administrator account this page names."; offline_access label "Stay connected", description "The agent may refresh its access without asking you to sign in again." Polish: backend label "Działaj też jako administrator", description "Agent może używać konta administratora, które wskazuje ta strona."; offline_access label "Pozostań połączony", description "Agent może odnawiać dostęp bez ponownego logowania."
When PendingRequestView.OfflineAccess is true, include offline_access in the show scopes array and in scope_details (locked false). grantedScopes intersects the submitted list with that augmented requested list, not only view.ScopesRequested. Strip the word offline_access before IssueCode. Do not change IssueCode's signature. A submit whose only remaining scope is offline_access still returns 422 no_grantable_scopes. Do not special-case read inside grantedScopes. The existing smoke subtest that posts scopes backend for a read-plus-backend request with no link must still get 422 no_grantable_scopes (D-06).
Follow WithConsentedBackend with a context value the MCP store reads in MarkIssued. When the POST body omits the scopes field, do not set the override, so the pending row's offline_access flag is kept (old all-or-nothing clients). When scopes is present, set the column from whether offline_access survived the intersection. Absent context means MarkIssued leaves the column as the pending row has it.
D-08 and D-12, after IssueCode returns nil: load the consent for (userID, clientID). Compare the requested set (data scopes plus offline_access when that flag was on the pending view) to the stored scopes. If a row exists and nothing requested is missing, call TouchConsent only and do not change scopes. If a row exists and something is missing, or there is no row, insert or replace scopes with the granted set, including the word offline_access when the flag was granted. Replace means the stored array becomes the granted set, not a union. Do not call oidc SaveConsent. Deny does not change the row. Add additive consent_required on the show body: false when a row exists and nothing requested is missing, true otherwise. Do not skip the authorize redirect; the user is known only on these jwt.auth routes.
A POST with decision allow and no scopes field still grants every requested data scope (D-06, old pages). Do not require scope_details or kind on the request.
revoke (D-12, D-15): inside the existing transaction, call classes.lockUserGrants for the user before the deletes. Then delete the consent, models.AuthCode, models.AccessToken, models.RefreshToken, and models.MCPCode for the same user_id and client_id. Keep 404 errNotFound when no consent exists. After commit, keep the existing MCPSubjectRevoked event with Guard user, ClientID set, and TokenID left zero. Do not add a second event or a second backend revoke. One delete of the access and refresh rows covers backend_user_id on those rows.
GET /oauth/authorizations: select c.kind, set authorization.Kind with json kind, last field. Always set it from the client kind, oidc or mcp. Update authorizations_smoke_test.go so the key list is client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind, in that order. The length check must match the new list. Existing OIDC rows in that test get kind oidc.
README: document mcp_scopes (unset, set, empty, fixed five always, boot error on an unknown name), scope_details and logo_url, consent replace versus the OIDC union, offline_access as a column, kind, and that revoke deletes MCP codes under lockUserGrants. Say that a non-nil DCR scope ceiling still drops names outside that ceiling and this phase does not rewrite old ceilings. Do not name a consuming application. Do not log bearers, codes, or refresh tokens. Commit in the plugin repo only. Do not push or tag. Do not write the backfill file in this task.
</action>
<verify>
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestAuthorizationsSmoke|TestMCPAdvertisedScopes' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestAuthorizationsSmoke has no PASS line</fails_when>
</verify>
<acceptance_criteria>
- Show JSON still has scopes as a string array, plus scope_details and client.logo_url.
- MCPConsentDecide does not call SaveConsent.
- authorizations_smoke_test.go expects seven keys ending in kind.
- The backend-only consent POST still expects 422 no_grantable_scopes.
- revoke deletes models.MCPCode after lockUserGrants, and the revoked event has no TokenID.
</acceptance_criteria>
<done>Consent, revoke, and the authorizations list behave as D-06 through D-13 and D-15, and the existing smokes pass.</done>
</task>
<task type="checkpoint:decision" gate="blocking" auto_select="write-noop-rollback">
<name>Confirm the one-way MCP consent backfill</name>
<reversibility rating="one-way">D-14 writes consent rows into golem15_oauthserver_consents on every install. A later rollback cannot tell those rows from consents the new code created.</reversibility>
<decision>Write the D-14 backfill that inserts one golem15_oauthserver_consents row per live MCP user and client, with Rollback as a no-op?</decision>
<context>D-14 is already the chosen behaviour. This checkpoint is the one-way door before the migration file exists. Rollback must not delete every MCP consent, because that would also delete consents created after the migration. The next task writes the file only if write-noop-rollback is accepted. On resume, before that task creates or refuses the migration, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt` containing only the accepted option id, `write-noop-rollback` or `hold`. Do not create the migration in this task.</context>
<options>
<option id="write-noop-rollback">
<name>Write the backfill, Rollback is a no-op</name>
<pros>Live MCP grants become revocable. A second migrate inserts nothing. Rollback cannot destroy later consents.</pros>
<cons>Rows stay after a down migration. Undo is a hand-written delete, not the migration Rollback.</cons>
</option>
<option id="hold">
<name>Do not write the migration in this run</name>
<pros>No rows are inserted on install until a later explicit decision.</pros>
<cons>Grants that predate this release still have no consent row, so per-client revoke answers 404 for them. D-14 stays unmet.</cons>
</option>
</options>
<resume-signal>Select write-noop-rollback or hold</resume-signal>
<read_first>
.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-14
../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go
</read_first>
<acceptance_criteria>
- The accepted option is recorded before any backfill file is created.
- That record is `32.1-03-backfill-decision.txt` in this phase directory and its entire text is `write-noop-rollback` or `hold`.
- hold means the next task does not add a migration.
</acceptance_criteria>
</task>
<task type="auto">
<name>Backfill MCP consents for live grants</name>
<reversibility rating="one-way">D-14 inserts consent rows on every consuming install. Rollback is a no-op and does not delete those rows.</reversibility>
<files>../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt</files>
<read_first>
../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go (Register pattern)
../sm-oauthserver-plugin/models/consent.go
../sm-oauthserver-plugin/models/access_token.go
../sm-oauthserver-plugin/models/refresh_token.go
../sm-oauthserver-plugin/models/client.go ClientKindMCP
</read_first>
<action>
Run this task only when the checkpoint option write-noop-rollback was accepted. If the accepted option is hold, stop without creating a migration file and say so in the summary. Do not rewrite `32.1-03-backfill-decision.txt` after this task starts changing the plugin tree. If you resumed and that record is not on disk yet, write the accepted option id into it before creating or deleting the migration, then leave the record unchanged. The verify reads that record and fails when it is absent or disagrees with the migration file.
Add updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go with migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents, registered from init via Register, so it sorts after 202610230001_add_golem15_oauthserver_backend_user_id. Migrate, in Go on the transaction: one row per (user_id, client_id) where the client kind is mcp and the pair has at least one live access or refresh token. Live means revoked_at IS NULL and (expires_at IS NULL or expires_at is in the future). Skip the pair when any consent row already exists. scopes is the union of those tokens' scopes arrays; if any live token has offline_access true, include the string offline_access. created_at and updated_at are the earliest created_at among the live tokens. last_used_at stays null. A second run inserts nothing. Rollback returns nil and its comment says the writes are one-way and a down migration must not delete MCP consents. Do not delete token rows. Commit in the plugin repo only. Do not push or tag.
</action>
<verify>
<automated>python3 -c 'import pathlib,sys; rec=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt"); mig=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go");
assert rec.is_file(), "checkpoint record absent"
opt=rec.read_text().strip()
exists=mig.is_file()
assert opt in ("write-noop-rollback","hold"), "checkpoint record is not an option id"
if opt=="hold":
assert not exists, "hold but migration file exists"
sys.exit(0)
text=mig.read_text() if exists else ""
assert exists, "write-noop-rollback but migration file missing"
for s in ("202610280001_backfill_golem15_oauthserver_mcp_consents","Register","golem15_oauthserver_consents"):
assert s in text, "migration missing "+s
i=text.find("Rollback")
assert i>=0, "Rollback missing"
rest=text[i:]
nl=rest.find("\nfunc ")
body=rest if nl<0 else rest[:nl]
assert ".Delete(" not in body and "DELETE FROM" not in body, "Rollback deletes"
assert "return nil" in body, "Rollback does not return nil"'</automated>
<fails_when>non-zero exit, stdout or stderr contains checkpoint record absent, checkpoint record is not an option id, hold but migration file exists, write-noop-rollback but migration file missing, migration missing, Rollback missing, Rollback deletes, or Rollback does not return nil</fails_when>
</verify>
<acceptance_criteria>
- When write-noop-rollback was accepted, the migration ID sorts after 202610230001_add_golem15_oauthserver_backend_user_id, Rollback does not delete consents, and the file is committed in the plugin repo.
- When hold was accepted, that file does not exist.
</acceptance_criteria>
<done>D-14 is either written as an idempotent no-op-rollback migration or explicitly held. It is not written before the checkpoint.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser POST /oauth/mcp/requests/{request} | Signed-in user submits decision and scopes. The body is untrusted. |
| DELETE /oauth/authorizations/{client_id} | Signed-in user names a client. Another user's row must stay. |
| Operator config mcp_scopes | Names enter metadata and the allow-list at boot. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32.1-06 | Elevation of privilege | MCP consent upsert | high | mitigate | Replace scopes on a real decision. Do not call SaveConsent, which unions. Silent re-grant only TouchConsent. Deny does not change the row (D-08). |
| T-32.1-07 | Elevation of privilege | authorizations.revoke | high | mitigate | lockUserGrants exclusive inside the transaction before deletes. Delete models.MCPCode for the pair plus consent, auth codes, access and refresh. Event is MCPSubjectRevoked with ClientID and zero TokenID (D-12, D-15). |
| T-32.1-08 | Information disclosure | PendingRequest / IssueCode | high | mitigate | Keep owner checks. Missing, foreign, expired and used stay wristband.ErrPendingNotFound. Do not add a lookup by request id alone. |
| T-32.1-09 | Tampering | grantedScopes when scopes is absent | medium | mitigate | A POST of decision allow without scopes still grants the whole requested set. scope_details and kind are response fields. Old pages ignore them. |
| T-32.1-10 | Information disclosure | consent and revoke logs | medium | mitigate | Do not log the bearer, the authorization code, or a refresh token. Errors stay the existing JSON codes. |
</threat_model>
<verification>
TestMCPAdvertisedScopes passes under -short. TestMCPConsentSmoke and TestAuthorizationsSmoke pass against Postgres. The backfill file exists only after write-noop-rollback.
</verification>
<success_criteria>
- One scope slice drives the MCP server and the verifier.
- Consent replace, offline_access, revoke-deletes-MCP-code, and kind are in the plugin.
- D-14 is gated and, when accepted, idempotent with a no-op rollback.
</success_criteria>
<output>
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md` when done.
</output>
## Artifacts this phase produces
- Config key golem15.oauthserver.mcp_scopes and env SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES
- Unexported advertised-scope builder used by NewServers
- MCPTokenVerifier.MCPScopesSupported
- Consent JSON fields scope_details (name, label, description, locked), client.logo_url, consent_required
- Phrase keys consent.mcp.backend, consent.mcp.backend_description, consent.mcp.offline_access, consent.mcp.offline_access_description in en and pl
- Context override read by mcpTx.MarkIssued for the offline_access column
- Replace upsert into golem15_oauthserver_consents
- authorization.kind json kind
- revoke deletes golem15_oauthserver_mcp_codes under lockUserGrants
- Migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents when the checkpoint accepts it

View File

@@ -0,0 +1,181 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
plan: 03
subsystem: auth
tags: [mcp, oauth, consent, gormigrate, sm-oauthserver-plugin]
requires:
- phase: 32.1-01
provides: pact.MCPAdvertisedScopes and offline access on the pending consent view
- phase: 32.1-02
provides: pact.MemberTokenScopeCatalog from sm-user-plugin
provides:
- "One MCP scope slice from golem15.oauthserver.mcp_scopes drives metadata and MCPTokenVerifier.MCPScopesSupported"
- "MCP consent replaces stored scopes, revoke deletes MCP codes under lockUserGrants, and authorizations carry kind"
- "Migration 202610280001 backfills one consent per live MCP grant and its Rollback does not delete rows"
affects: [32.1-05]
actuals:
tokens: 21059
tasks: 4
commits: 0
plan_head_before: 3f52b1ab495b487b03b8e534bdfc6b6676104b00
plan_head_after: 3f52b1ab495b487b03b8e534bdfc6b6676104b00
tech-stack:
added: []
patterns:
- "MCP scope slice is built once in NewServers and stored on the verifier; OIDC scopes stay untouched"
- "MCP consent replace writes golem15_oauthserver_consents directly and does not call SaveConsent"
- "The D-14 backfill is one-way: Rollback returns nil and does not delete consents"
key-files:
created:
- ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
- .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt
modified:
- ../sm-oauthserver-plugin/classes/mcp_scopes.go
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/README.md
key-decisions:
- "Checkpoint write-noop-rollback: backfill live MCP grants into golem15_oauthserver_consents, and Rollback returns nil so a down migration cannot delete consents created later"
- "Scope union walks live tokens by created_at (nulls last), then access before refresh, then id; offline_access is appended when any live refresh token has that column set"
patterns-established:
- "Pattern: an unknown golem15.oauthserver.mcp_scopes name fails boot and names the key"
- "Pattern: MCP revoke takes lockUserGrants, deletes the consent and MCP codes, and emits MCPSubjectRevoked with a zero TokenID"
requirements-completed: []
coverage:
- id: D1
description: "Unset mcp_scopes advertises the five fixed names plus every declared member scope; a set list keeps only those declared names; an empty list is the five fixed names; an unknown name fails boot. OIDC discovery scopes stay openid, profile, email."
verification:
- kind: unit
ref: mcp_scopes_test.go#TestMCPAdvertisedScopes
status: pass
human_judgment: false
- id: D2
description: "MCP consent show adds scope_details and client.logo_url, an allow replaces stored scopes instead of calling SaveConsent, revoke deletes MCP codes under lockUserGrants, and GET /oauth/authorizations ends with kind."
verification:
- kind: integration
ref: mcp_consent_smoke_test.go#TestMCPConsentSmoke
status: pass
- kind: integration
ref: authorizations_smoke_test.go#TestAuthorizationsSmoke
status: pass
human_judgment: false
- id: D3
description: "The accepted checkpoint is write-noop-rollback. Migration 202610280001_backfill_golem15_oauthserver_mcp_consents is registered, names golem15_oauthserver_consents, and its Rollback returns nil and does not delete."
verification:
- kind: other
ref: python3 checkpoint record and migration text gate in 32.1-03-PLAN.md task 4
status: pass
human_judgment: true
rationale: "The plan gate checks the decision file and that Rollback returns nil without a delete. It does not insert a live token and migrate twice. Plan 05 TestMCPConsentBackfillIdempotent is the row-level proof."
duration: 24min
completed: 2026-10-28
status: complete
---
# Phase 32.1 Plan 03: MCP scope slice, consent replace, and one-way backfill Summary
**One MCP scope slice drives metadata and the verifier, consent replace and revoke delete MCP codes, and a no-op-rollback migration backfills consents for live grants**
## Performance
- **Duration:** 24 min
- **Started:** 2026-10-28T14:18:42Z
- **Completed:** 2026-10-28T14:43:00Z
- **Tasks:** 4
- **Files modified:** 17
The clock starts at the task 1 commit. This session resumed after the checkpoint.
## Accomplishments
- `golem15.oauthserver.mcp_scopes` filters declared member scopes. The fixed names `read`, `write`, `ai`, `offline_access`, and `backend` are always advertised, in that order, then declared names. An unknown name fails boot and names the key. `MCPTokenVerifier.MCPScopesSupported` returns that same slice. OIDC scopes are not rewritten.
- An MCP allow replaces `golem15_oauthserver_consents` for the user and client and does not call `SaveConsent`. `offline_access` stays a column, copied into the consent scope list when it was granted. Revoke takes `lockUserGrants`, deletes the consent, OIDC codes, MCP codes, access tokens, and refresh tokens, then emits `MCPSubjectRevoked` with `ClientID` set and `TokenID` left zero. `GET /oauth/authorizations` always ends with `kind` `oidc` or `mcp`.
- Checkpoint option `write-noop-rollback` was accepted. The decision file was written before the migration existed. `202610280001_backfill_golem15_oauthserver_mcp_consents` inserts one consent per MCP user and client that has a live access or refresh token and no consent yet. Live means `revoked_at` is null and `expires_at` is null or still in the future. A second run inserts nothing. `Rollback` returns nil and does not delete consents.
## Task Commits
Plugin repo `sm-oauthserver-plugin` on `master`. summercms.go had no commits in the measured range `3f52b1ab495b487b03b8e534bdfc6b6676104b00..HEAD` at summary time (`commits: 0`). The decision record is committed with this summary.
1. **Task 1: Advertise one MCP scope slice** - `644641a` (feat)
2. **Task 2: MCP consent replace, revoke, and kind** - `52529c6` (feat)
3. **Task 3: Confirm the one-way MCP consent backfill** - option `write-noop-rollback` recorded in `32.1-03-backfill-decision.txt` before the migration file was created. No plugin commit.
4. **Task 4: Backfill MCP consents for live grants** - `a409074` (feat)
**Plan metadata:** this summary, the decision record, and the roadmap progress update.
## Files Created/Modified
- `../sm-oauthserver-plugin/config/config.yaml` - `mcp_scopes` and the `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES` spelling
- `../sm-oauthserver-plugin/classes/mcp_scopes.go` - the one advertised slice and the unknown-name boot error
- `../sm-oauthserver-plugin/classes/mcp_verifier.go` - `MCPScopesSupported`
- `../sm-oauthserver-plugin/servers.go` - assigns that slice only to the MCP server
- `../sm-oauthserver-plugin/mcp_scopes_test.go` - unset, set, empty, and unknown name
- `../sm-oauthserver-plugin/controllers/mcp_consent_api.go` - `scope_details`, `logo_url`, `offline_access`, consent replace
- `../sm-oauthserver-plugin/controllers/authorizations.go` - `kind` and revoke of MCP codes under `lockUserGrants`
- `../sm-oauthserver-plugin/classes/mcp_store.go` - `MarkIssued` reads the offline-access override
- `../sm-oauthserver-plugin/classes/mcp_backend_binding.go` - context value for that override
- `../sm-oauthserver-plugin/lang/en/lang.yaml` - backend and offline_access consent phrases
- `../sm-oauthserver-plugin/lang/pl/lang.yaml` - the same phrases in Polish
- `../sm-oauthserver-plugin/README.md` - scope filter, replace versus the OIDC union, kind, revoke, and old DCR ceilings
- `../sm-oauthserver-plugin/authorizations_smoke_test.go` - seven keys ending in `kind`
- `../sm-oauthserver-plugin/mcp_consent_smoke_test.go` - smoke expectations kept in step with the new JSON
- `../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` - idempotent one-way consent backfill
- `32.1-03-backfill-decision.txt` - the entire text is `write-noop-rollback`
## Decisions Made
- The checkpoint accepted `write-noop-rollback`. Rollback must not delete every MCP consent, because those rows cannot be told from consents the new code creates after the migration. Undo is a hand-written delete, not the migration Rollback.
- The union walks live tokens from earliest `created_at` (nulls last), access before refresh on a tie, then id. Empty scope names are dropped. `offline_access` is added at the end when any live refresh token has the column set and the word is not already present. Access tokens have no such column. `created_at` and `updated_at` are that earliest token time, or null when every live token has none. `last_used_at` stays null. A pair with any existing consent row is skipped, and the insert also uses `WHERE NOT EXISTS`.
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. A phase-scoped decision and metric may be recorded in STATE.md. STATE.md and state.json are not committed.
`go.work` in sm-oauthserver-plugin was already dirty (one replace line). It was not staged, reverted, or committed.
The plugin commit is on `master`, which is where tasks 1 and 2 of this plan already landed. This resume required that. Nothing was pushed or tagged.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Plan 05 can add `TestMCPConsentBackfillIdempotent` against this migration. A live MCP access token with no consent should gain one row. A refresh token with `offline_access` true should add that word. A pair that already has a consent must be left unchanged. A second migrate inserts nothing. `Rollback` returns nil.
Plan 04 (vue-oauth-layer) can read `scope_details`, `logo_url`, `consent_required`, and `kind`. It does not depend on the backfill having run.
## Self-Check: PASSED
- FOUND: ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
- FOUND: ../sm-oauthserver-plugin/config/config.yaml
- FOUND: ../sm-oauthserver-plugin/classes/mcp_scopes.go
- FOUND: ../sm-oauthserver-plugin/classes/mcp_verifier.go
- FOUND: ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
- FOUND: ../sm-oauthserver-plugin/controllers/authorizations.go
- FOUND: ../sm-oauthserver-plugin/README.md
- FOUND: 32.1-03-backfill-decision.txt (`write-noop-rollback`)
- FOUND: 644641a
- FOUND: 52529c6
- FOUND: a409074
- Python task-4 gate: exit 0
- `go test -count=1 -timeout 240s -run 'TestAuthorizationsSmoke|TestMCPAdvertisedScopes'` exit 0
- `go test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestMCPAdvertisedScopes'` exit 0
- `TestAuthorizationsSmoke` PASS against Postgres, so the new migration applies on an empty database
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
*Completed: 2026-10-28*

View File

@@ -0,0 +1,183 @@
---
phase: 32.1
plan: 04
type: execute
wave: 4
depends_on:
- 32.1-03
files_modified:
- ../vue-oauth-layer/package.json
- ../vue-oauth-layer/nuxt.config.ts
- ../vue-oauth-layer/app/pages/connect.vue
- ../vue-oauth-layer/app/utils/connectConsent.ts
- ../vue-oauth-layer/tests/connect.test.ts
- ../vue-oauth-layer/README.md
- ../vue-oauth-layer/.playground/nuxt.config.ts
autonomous: true
requirements: []
estimate:
tokens: 42000
raw_tokens: 42000
tasks: 2
confidence: low
must_haves:
truths:
- "SC-3: The layer page /connect renders the MCP consent show JSON as checkboxes. read is locked in the UI. The server is not asked to force read into the grant (D-06, D-17)."
- "Client name, logo, scope label, scope description and the admin name are text, never raw HTML (D-07)."
- "backend shows the also-act-as-admin line when backend_admin is set. A 409 backend_link_changed fetches the show again (D-17)."
- "No application /connect page is edited, replaced, or deleted. The repo is not tagged or pushed in this plan."
artifacts:
- path: ../vue-oauth-layer/app/pages/connect.vue
provides: "Nuxt layer page /connect"
contains: "connectConsent"
- path: ../vue-oauth-layer/app/utils/connectConsent.ts
provides: "Pure mapping from show JSON to POST body"
contains: "allowBody"
- path: ../vue-oauth-layer/tests/connect.test.ts
provides: "node:test coverage of the mapping"
contains: "node:test"
key_links:
- from: app/pages/connect.vue
to: app/utils/connectConsent.ts
via: the page imports the pure functions the tests import
pattern: allowBody
- from: allowBody
to: POST /oauth/mcp/requests/{request}
via: JSON decision, scopes, backend_admin
pattern: decision
---
<objective>
Initial public Nuxt layer vue-oauth-layer, one plan (D-17, D-06, D-07). It provides /connect for redirect consent from the MCP consent JSON plan 03 returns. Apps adopt it later. This plan does not migrate any app and does not tag or push.
Purpose: a redirect-mode application can render per-scope consent without copying a page, while an application that already has /connect keeps its own page because Nuxt project files win over a layer.
Output: a new repo beside vue-menu-layer, with node --test and a README. The first tag is plan 05's checkpoint, not this plan.
Copy vue-menu-layer's skeleton (fileURLToPath, node --test, no extra runtime dependencies), not vue-journal-layer. Sibling path of the template: /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer. New repo path: /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md
@../vue-menu-layer/package.json
@../vue-menu-layer/nuxt.config.ts
@../vue-menu-layer/tests/menuLinks.test.ts
@../sm-oauthserver-plugin/controllers/mcp_consent_api.go
</context>
<tasks>
<task type="tracer">
<name>Tracer: show JSON maps to a locked-read allow body</name>
<precondition>/media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/package.json exists, and /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer is either absent or already this phase's new repo. Halt if that path is some other project.</precondition>
<reversibility rating="costly">D-17: the layer's /connect props and POST body become the contract apps will pin by tag.</reversibility>
<files>../vue-oauth-layer/package.json, ../vue-oauth-layer/nuxt.config.ts, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/tests/connect.test.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
<read_first>
../vue-menu-layer/package.json (engines, peer nuxt 4.4.8, dev vue 3.5.35 and typescript 5.9.3, test script)
../vue-menu-layer/nuxt.config.ts (fileURLToPath)
../vue-menu-layer/tests/menuLinks.test.ts (node:test and node:assert/strict importing a ts util)
../sm-oauthserver-plugin/controllers/mcp_consent_api.go show and decide bodies, after plan 03
</read_first>
<action>
Create the repo with git init at /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer. Do not add a remote, do not push, do not tag.
package.json name vue-oauth-layer, version 0.1.0, private true, type module, main ./nuxt.config.ts, engines node >= 22.18, peerDependency nuxt 4.4.8, devDependencies nuxt 4.4.8, typescript 5.9.3, vue 3.5.35. Script test is node --test --test-reporter=tap tests/*.test.ts. No other runtime dependency. Do not add sanitize-html, an SEO module, or i18n.
nuxt.config.ts builds every path with fileURLToPath(new URL('.', import.meta.url)). No relative layer path. No Nuxt modules. compatibilityDate 2025-07-15. devtools disabled.
app/utils/connectConsent.ts exports pure functions the page and the tests both import. scopeBoxes(show) returns one box per scope_details entry, each checked to start, with locked copied from the detail. allowBody(show, clearedNames) returns decision allow. When show.consent_required is false, the body is decision allow and omits scopes, so the server keeps the stored set. Otherwise scopes is the checked names. A box with locked true cannot be cleared: read stays in scopes when it was requested. denyBody returns decision deny. backendAdminLogin(show) is show.backend_admin.login when backend_admin is non-null and backend is among the scopes, else absent. shouldRefetch(status, errorCode) is true only for HTTP 409 and error backend_link_changed.
app/pages/connect.vue reads the request query, calls those functions, and posts JSON to /oauth/mcp/requests/{request} on the same origin with credentials included. It does not add a Nitro proxy. Render label, description, client name and admin name as text. Do not use v-html. The tracer page must already import connectConsent.ts so the test and the page share one implementation. A 401 redirects to the loginPath prop, default /login.
tests/connect.test.ts: one show payload with scope_details for read (locked true) and write (locked false), clearing write, allowBody scopes are read only. consent_required false yields a body without a scopes key. The test reads app/pages/connect.vue and fails if the source contains v-html. Run pnpm install in the new repo so the test script's node can resolve the package, then commit only inside vue-oauth-layer. Do not git add -A. Do not edit any app.
</action>
<verify>
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
</verify>
<acceptance_criteria>
- pnpm test exits 0 and the locked-read case passes.
- connect.vue imports the same module the test imports.
- No v-html in app/pages/connect.vue.
- No app repository has a diff from this task.
</acceptance_criteria>
<done>A show payload becomes an allow POST that keeps locked read, and the page uses that function.</done>
</task>
<task type="auto">
<name>Finish /connect and the layer README</name>
<reversibility rating="costly">The page copy and the README install line are what a later tag publishes (D-17).</reversibility>
<files>../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/tests/connect.test.ts, ../vue-oauth-layer/README.md, ../vue-oauth-layer/.playground/nuxt.config.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
<read_first>
../vue-oauth-layer/app/utils/connectConsent.ts
../vue-oauth-layer/app/pages/connect.vue
../vue-menu-layer/README.md install and extends example, as the shape to copy with this layer's name
../sm-oauthserver-plugin/controllers/mcp_consent_api.go backend_link_changed and backend_admin
</read_first>
<action>
Complete the page (D-17). Show the client name and, when logo_url is non-empty, an img whose alt is the client name as text. Every requested scope starts checked. read's checkbox is disabled and checked, and the POST from a shown form always includes read when it was requested. The server does not force read; do not send a flag that would make it do so. offline_access, backend and declared scopes can be cleared. When backend_admin is non-null and backend is requested, show the sentence "also act as admin" followed by the admin name as text, and send backend_admin set to backend_admin.login. Allow posts decision allow. Deny posts decision deny. On 409 with error backend_link_changed, fetch the show JSON again and render it. When consent_required is false, post decision allow immediately and do not render checkboxes.
Do not log the user in. Do not read a product name from an app. loginPath prop default remains /login, documented as the handoff when the JSON route answers 401.
Tests cover: locked read stays in the POST when the box cannot be cleared; a cleared write is absent; consent_required false omits scopes; 409 backend_link_changed is the refetch signal and a 422 is not; backend_admin.login is sent only when the line is shown. The source check that rejects v-html stays.
README: what the layer is, the install shape pnpm add git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z, and extends ['vue-oauth-layer']. Say the tag does not exist until a later release the user accepts. Say an application's own app/pages/connect.vue wins over this layer, and this repo does not edit those pages. Do not name a consuming application. .playground/nuxt.config.ts extends the layer via fileURLToPath of the parent directory so a local dev server resolves the layer. No new dependency. Commit in the layer repo only. Do not push or tag.
</action>
<verify>
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
</verify>
<acceptance_criteria>
- The page implements allow, deny, locked read, the admin line, and the 409 re-show.
- README shows the git pin shape and extends, and does not claim a tag was pushed.
- pnpm test passes.
</acceptance_criteria>
<done>D-17's /connect behaviour is in the layer, tested, and not tagged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP consent JSON to the browser | Client name, labels and admin name are attacker-influenced (DCR name, phrase text). |
| Page POST back to the consent route | The browser can omit fields. The server intersection is the control. This page must not widen it. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32.1-11 | Tampering | app/pages/connect.vue | high | mitigate | Render label, description, client name and admin name as text. The unit test fails if the page source contains v-html. Do not bind those strings as HTML. |
| T-32.1-12 | Elevation of privilege | read checkbox | low | accept | The checkbox is disabled and the page's own POST includes read when the form was shown. A client that omits read only narrows the grant. The server must not reinsert read, because the existing 422 smoke posts backend alone. Narrowing is not a privilege gain. |
</threat_model>
<verification>
pnpm test in vue-oauth-layer passes. No app /connect file is modified. No tag and no push.
</verification>
<success_criteria>
- /connect maps the consent JSON, locks read in the UI, and refetches on backend_link_changed.
- The layer has no runtime dependency beyond the Nuxt peer the menu layer already pins.
</success_criteria>
<output>
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md` when done.
</output>
## Artifacts this phase produces
- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
- Repo /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
- app/pages/connect.vue
- app/utils/connectConsent.ts exports scopeBoxes, allowBody, denyBody, backendAdminLogin, shouldRefetch
- tests/connect.test.ts
- Prop loginPath default /login
- README install shape git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z

View File

@@ -0,0 +1,185 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
plan: 04
subsystem: auth
tags: [nuxt, oauth, mcp, consent, vue-oauth-layer]
requires:
- phase: 32.1-03
provides: MCP consent show JSON with scope_details, logo_url, consent_required, and 409 backend_link_changed
provides:
- "Public Nuxt layer vue-oauth-layer with /connect for MCP redirect consent"
- "allowBody keeps locked read, omits scopes when consent_required is false, and sends backend_admin.login only with the admin line"
- "A 409 backend_link_changed refetches the show; the layer is not tagged or pushed"
affects: [32.1-05]
actuals:
tokens: 65479
tasks: 2
commits: 0
plan_head_before: 23238ccd47b615529948d4f094713928272a308a
plan_head_after: 23238ccd47b615529948d4f094713928272a308a
tech-stack:
added: []
patterns:
- "vue-oauth-layer copies the vue-menu-layer skeleton: fileURLToPath paths, nuxt 4.4.8 peer, node --test, no extra runtime dependency"
- "The page and the tests import app/utils/connectConsent.ts; labels are text interpolations"
key-files:
created:
- ../vue-oauth-layer/package.json
- ../vue-oauth-layer/nuxt.config.ts
- ../vue-oauth-layer/app/pages/connect.vue
- ../vue-oauth-layer/app/utils/connectConsent.ts
- ../vue-oauth-layer/tests/connect.test.ts
- ../vue-oauth-layer/README.md
- ../vue-oauth-layer/.playground/nuxt.config.ts
modified: []
key-decisions:
- "The new repo's branch is main, matching the other Nuxt layers. It has no remote and no tag."
- "A 409 backend_link_changed renders the show again and does not post immediately, so a stored consent cannot loop."
- "backend_admin.login is sent when the admin line applies and backend stays in the grant, including when scopes are omitted because consent_required is false."
patterns-established:
- "Pattern: scopeBoxes starts every scope_details entry checked and copies locked. allowBody drops cleared names except locked ones."
- "Pattern: loginPath defaults to /login and is only the 401 handoff. The layer does not sign the user in."
requirements-completed: []
coverage:
- id: D1
description: "/connect maps the MCP consent show JSON to checkboxes, keeps locked read in the allow POST, and renders client name, labels, descriptions, and the admin name as text."
verification:
- kind: unit
ref: vue-oauth-layer/tests/connect.test.ts
status: pass
human_judgment: false
- id: D2
description: "The admin line, logo alt text, 409 refetch, and an immediate allow when consent_required is false are implemented and covered. The README shows the git pin shape and says the tag does not exist yet."
verification:
- kind: unit
ref: vue-oauth-layer/tests/connect.test.ts
status: pass
- kind: other
ref: README.md install line and empty git tag list
status: pass
human_judgment: false
duration: 14min
completed: 2026-10-28
status: complete
---
# Phase 32.1 Plan 04: vue-oauth-layer /connect Summary
**A new Nuxt layer renders MCP redirect consent at /connect, locks read in the UI, and refetches when the administrator link changes**
## Performance
- **Duration:** 14 min
- **Started:** 2026-10-28T14:45:32Z
- **Completed:** 2026-10-28T14:59:10Z
- **Tasks:** 2
- **Files modified:** 13
`actuals.tokens` is chars/4 of `git log -p` in vue-oauth-layer (261918 / 4). The pnpm lockfile is most of that diff. summercms.go has no production commits in the measured range.
## Accomplishments
- Created `/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer` with `git init -b main`. No remote, no tag, no push. Peer dependency is nuxt 4.4.8. Dev dependencies are nuxt 4.4.8, typescript 5.9.3, and vue 3.5.35. There is no other runtime dependency.
- `app/utils/connectConsent.ts` maps show JSON to checkboxes and the POST body. Locked `read` stays in `scopes` when it was requested. `consent_required: false` omits `scopes`. `shouldRefetch` is true only for HTTP 409 and `backend_link_changed`.
- `app/pages/connect.vue` imports that module, reads `?request=`, and calls `GET` and `POST /oauth/mcp/requests/{request}` on the same origin with credentials included. It does not add a Nitro proxy. Client name, scope label, scope description, logo alt, and the administrator name are text. The page source does not contain `v-html`. A 401 navigates to the `loginPath` prop, default `/login`.
- When `backend_admin` is set and `backend` was requested, the page shows "also act as admin" and the administrator name, and the allow body sends `backend_admin.login` unless `backend` was cleared. A non-empty `logo_url` is an image. When `consent_required` is false the page posts allow immediately and does not render checkboxes. A 409 `backend_link_changed` loads the show again and renders it instead of posting again.
- `pnpm test` passes: 8 tests, 0 failed.
## Task Commits
Layer repo `vue-oauth-layer` on `main`. summercms.go had no commits in the measured range `23238ccd47b615529948d4f094713928272a308a..HEAD` at summary time (`commits: 0`).
1. **Task 1: Tracer: show JSON maps to a locked-read allow body** - `02c466e` (feat)
2. **Task 2: Finish /connect and the layer README** - `37c1e12` (feat)
3. **Rule 1 follow-up: empty redirect leaves the page ready** - `7f805b3` (fix)
**Plan metadata:** this summary and the roadmap progress update, committed in summercms.go after this file was written.
## Files Created/Modified
- `../vue-oauth-layer/package.json` - layer package, test script, pinned Nuxt peer
- `../vue-oauth-layer/nuxt.config.ts` - absolute css path via fileURLToPath, no modules
- `../vue-oauth-layer/app/utils/connectConsent.ts` - scopeBoxes, allowBody, denyBody, backendAdminLogin, shouldRefetch
- `../vue-oauth-layer/app/pages/connect.vue` - /connect page
- `../vue-oauth-layer/app/assets/css/connect.css` - neutral tokens for the page
- `../vue-oauth-layer/tests/connect.test.ts` - node:test mapping and a source check against raw HTML
- `../vue-oauth-layer/README.md` - install shape, extends, loginPath, and the unpushed tag
- `../vue-oauth-layer/.playground/nuxt.config.ts` - playground extends the layer by an absolute path
- `../vue-oauth-layer/tsconfig.json`, `.gitignore`, `.nvmrc`, `pnpm-workspace.yaml`, `pnpm-lock.yaml` - menu-layer skeleton so install and Vite resolve inside the layer
## Decisions Made
- The repository branch is `main`, the same branch the sibling layers use. `git init` alone would have created `master` because `init.defaultBranch` is master. The default-branch guard reports `main` as protected: this repo has no `.planning/config.json`, so `git.allow_default_branch_commits` is not set. The commits are on `main` anyway. This is a new repository whose only branch is the one the plan publishes. It is not a commit on summercms.go's default branch. No planning config was added to the layer to silence the guard.
- After `409 backend_link_changed` the page fetches the show and renders it. It does not post that show immediately. An immediate allow would send the same decision again and loop while `consent_required` stays false.
- `backend_admin` is omitted when the user clears `backend`, because the server only checks that field when `backend` is in the grant. When `consent_required` is false, scopes are omitted and `backend_admin.login` is still sent if the admin line applies, so the full stored grant does not 409 on the linked administrator.
- A decision response with an empty `redirect_uri` sets the page back to ready instead of leaving it on Loading.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical] Menu-layer skeleton files the plan list did not name**
- **Found during:** Task 1
- **Issue:** `pnpm install` creates `node_modules`, Vite reads `tsconfig.json` from the layer, and `nuxt.config.ts` was required to build every path with `fileURLToPath`. A config with no path would not satisfy that rule.
- **Fix:** Added `.gitignore`, `tsconfig.json`, `.nvmrc`, `pnpm-workspace.yaml` (the menu layer's `allowBuilds`), `pnpm-lock.yaml`, and `app/assets/css/connect.css` wired from the absolute layer directory. `dev` and `dev:prepare` scripts match the menu layer so the playground can start. No package beyond the pinned Nuxt, Vue, and TypeScript set was added.
- **Files modified:** those skeleton files in vue-oauth-layer
- **Commit:** `02c466e`
**2. [Rule 1 - Bug] Empty redirect_uri left the page on Loading**
- **Found during:** Task 2, after the page commit
- **Issue:** A 200 decision body with an empty `redirect_uri` never left `submitting`, so the template stayed on Loading.
- **Fix:** Set state back to ready when the body has no redirect.
- **Files modified:** `app/pages/connect.vue`
- **Commit:** `7f805b3`
**3. [Rule 3 - Blocking] `git init -b main` instead of the global default `master`**
- **Found during:** Task 1
- **Issue:** `init.defaultBranch` is `master`. vue-menu-layer, vue-journal-layer, vue-faq-layer, and vue-paymentgateway-layer are all on `main`.
- **Fix:** Initialized the repo with `-b main`.
- **Files modified:** none (branch name only)
- **Commit:** `02c466e` (root commit on `main`)
## Issues Encountered
`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. A phase-scoped decision and metric may be recorded in STATE.md. STATE.md and state.json are not committed.
The default-branch guard returned protected for `main` inside the new repo. See Decisions Made. Nothing was pushed or tagged.
## User Setup Required
None - no external service configuration required.
The install line in the README does not resolve until someone accepts a later release and the tag exists. Plan 05 owns that checkpoint. This plan did not create the git remote `git.golem15.com/golem15/vue-oauth-layer.git`.
## Next Phase Readiness
Plan 05 can run `pnpm test` in vue-oauth-layer. The first tag, if the release checkpoint says so, is v0.1.0 on this repo. An application that already has `app/pages/connect.vue` keeps that page. This plan did not edit one.
## Self-Check: PASSED
- FOUND: ../vue-oauth-layer/package.json
- FOUND: ../vue-oauth-layer/nuxt.config.ts
- FOUND: ../vue-oauth-layer/app/pages/connect.vue
- FOUND: ../vue-oauth-layer/app/utils/connectConsent.ts
- FOUND: ../vue-oauth-layer/tests/connect.test.ts
- FOUND: ../vue-oauth-layer/README.md
- FOUND: ../vue-oauth-layer/.playground/nuxt.config.ts
- FOUND: 02c466e
- FOUND: 37c1e12
- FOUND: 7f805b3
- `pnpm test`: 8 passed, 0 failed, no `not ok`
- `git remote` empty, `git tag` empty
- `app/pages/connect.vue` does not contain `v-html`
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
*Completed: 2026-10-28*

View File

@@ -0,0 +1,383 @@
---
phase: 32.1
plan: 05
type: execute
wave: 5
depends_on:
- 32.1-01
- 32.1-02
- 32.1-03
- 32.1-04
files_modified:
- modules/pact/mcp_call_negative_test.go
- modules/pier/dispatch_mcpcall_test.go
- modules/pier/prm_scopes_test.go
- ../sm-user-plugin/classes/mcp_guard_test.go
- ../sm-oauthserver-plugin/mcp_consent_test.go
- ../sm-oauthserver-plugin/authorizations_kind_test.go
- ../sm-oauthserver-plugin/mcp_backfill_test.go
- ../sm-oauthserver-plugin/go.mod
autonomous: false
requirements: []
estimate:
tokens: 58000
raw_tokens: 58000
tasks: 4
confidence: low
must_haves:
truths:
- "SC-2: MCPCallFrom on a bare context is ok false, and a frontend tool and a backend tool each carry MCPCall for the guard that owns the tool (D-09, D-11)."
- "SC-1: Implies cases on the v0.1.14 worktree cover OAuth read, personal-token non-expansion, and write not satisfying Implies read (D-01a). PRM with and without MCPAdvertisedScopes is asserted (D-04)."
- "SC-3: Consent replace does not merge, offline_access follows the column, revoke deletes the MCP code, authorizations kind is mcp or oidc, and the backfill is idempotent when the migration exists (D-06, D-08, D-12, D-13, D-14)."
- "SC-4: TestDocsTree and docs:build --check pass. Tags, pushes, and the changelog post happen only after the release checkpoint accepts them (D-16)."
artifacts:
- path: modules/pact/mcp_call_negative_test.go
provides: "Bare-context MCPCallFrom and a FrontendTokenScope literal without Implies"
contains: "TestMCPCallFromBareContext"
- path: modules/pier/dispatch_mcpcall_test.go
provides: "Frontend and backend dispatch both set MCPCall"
contains: "TestDispatchMCPCallBothGuards"
- path: ../sm-oauthserver-plugin/mcp_consent_test.go
provides: "Replace, offline_access, and revoke-deletes-code cases"
contains: "TestMCPConsentReplace"
key_links:
- from: modules/pier/dispatch_mcpcall_test.go
to: pact.MCPCallFrom
via: a real tools/call on each guard
pattern: MCPCallFrom
- from: mcp_backfill_test.go
to: migration 202610280001_backfill_golem15_oauthserver_mcp_consents
via: two migrate runs
pattern: backfill
---
<objective>
Last plan of the phase: the unit tests that prove SC-1, SC-2, SC-3 and SC-4 beyond the tracer smokes of plans 01–04, then a human gate before any tag, push, or changelog post (D-16).
Purpose: the project rule puts the broad test pass last. Tagging is not part of an autonomous task.
Output: test files in the framework repo, the user-plugin worktree, and sm-oauthserver-plugin; pnpm test of vue-oauth-layer; docs checker. Tags only if the checkpoint option is tag-and-publish.
Do not edit app submodules, do not turn mcp.enabled on in an application, and do not hardcode the next framework version.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md
@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VALIDATION.md
@modules/pier/dispatch_test.go
@modules/pier/tracer_smoke_test.go
@modules/pact/mcp_call_test.go
@../sm-oauthserver-plugin/mcp_consent_smoke_test.go
@../sm-oauthserver-plugin/testmain_test.go
</context>
<tasks>
<task type="tracer">
<name>Tracer: bare context has no MCP call, and both guards' tool calls do</name>
<reversibility rating="reversible">Tests only. They lock the accessor contract already added in plan 01.</reversibility>
<files>modules/pact/mcp_call_negative_test.go, modules/pier/dispatch_mcpcall_test.go, modules/pier/prm_scopes_test.go</files>
<read_first>
modules/pact/mcp_call_test.go (do not weaken TestMCPCallRoundTrip)
modules/pier/mcp_seam_test.go
modules/pier/dispatch_test.go and modules/pier/tracer_smoke_test.go for the mount helpers
modules/pier/backend_guard.go BackendGuard constant
modules/pier/prm.go
</read_first>
<action>
Add tests. Do not change production behaviour unless a test shows the plan 01 setter missed a guard; the setter in dispatcher.run is shared, so a miss is a test-setup bug, not a second setter.
modules/pact/mcp_call_negative_test.go: TestMCPCallFromBareContext asserts MCPCallFrom(context.Background()) and MCPCallFrom on a request context that never called WithMCPCall return ok false (D-11). TestFrontendTokenScopeLiteralWithoutImplies builds FrontendTokenScope{Name, Label, Description} with no Implies field and asserts Implies is empty, so existing composite literals stay valid.
modules/pier/prm_scopes_test.go: TestPRMUsesAdvertisedScopes mounts pier with a verifier that implements MCPAdvertisedScopes and expects that slice on the protected-resource document, including a declared name. TestPRMFallsBackToPRMScopes mounts pier with a verifier that does not implement the interface and expects PRMScopes, which includes backend (D-04).
modules/pier/dispatch_mcpcall_test.go: TestDispatchMCPCallBothGuards, package pier_test, calls one frontend tool and one backend tool. Each handler reads pact.MCPCallFrom and records Guard and Tool. The frontend call's Guard is the frontend subject's guard. The backend call's Guard is the backend guard name. SessionID is not the word ephemeral. A personal-token versus OAuth difference is not asserted here; that is the user-plugin test in the next task. Do not print tokens. Commit in the framework repo, staging only these test files. Do not git add -A.
</action>
<verify>
<automated>go test -short -count=1 -run 'TestMCPCallFromBareContext|TestFrontendTokenScopeLiteralWithoutImplies|TestPRMUsesAdvertisedScopes|TestPRMFallsBackToPRMScopes|TestDispatchMCPCallBothGuards' ./modules/pact ./modules/pier</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or any of those five test names has no PASS line</fails_when>
</verify>
<acceptance_criteria>
- All five named tests PASS.
- The backend tool's recorded guard is the backend guard, and the frontend tool's guard is the frontend subject.
- No production file is in this commit.
</acceptance_criteria>
<done>SC-2's negative and both guards, and SC-1's PRM fallback, are proven in the framework repo.</done>
</task>
<task type="auto">
<name>Plugin, layer, and docs proofs for SC-1 through SC-4</name>
<precondition>docker info exits 0; the user-plugin worktree exists at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin on phase-32.1-mcp-implied-scopes; vue-oauth-layer/package.json exists.</precondition>
<reversibility rating="reversible">Tests and a docs-checker run. No schema write except the oauthserver test database.</reversibility>
<files>../sm-user-plugin/classes/mcp_guard_test.go, ../sm-oauthserver-plugin/mcp_consent_test.go, ../sm-oauthserver-plugin/authorizations_kind_test.go, ../sm-oauthserver-plugin/mcp_backfill_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer</files>
<read_first>
The worktree classes/mcp_guard_test.go (keep TestMCPImpliesOAuth)
../sm-oauthserver-plugin/mcp_consent_test.go and mcp_consent_smoke_test.go
../sm-oauthserver-plugin/testmain_test.go (freshDB skips when testing.Short)
../sm-oauthserver-plugin/authorizations_smoke_test.go
../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go if it exists
../vue-oauth-layer/tests/connect.test.ts
</read_first>
<action>
sm-user-plugin, on the v0.1.14 worktree, extend classes/mcp_guard_test.go. Do not edit an app submodule. Add TestMCPImpliesPersonalUnchanged: ClientID empty, scopes read, catalog Implies read on servers:read, MCPAllows rejects a requirement of servers:read, and MCPDispatch credential ID equals TokenID and does not HasScope servers:read. Add TestMCPImpliesWriteDoesNotGrantRead: OAuth ClientID set, scopes write, Implies read, MCPAllows rejects servers:read. Add TestMCPImpliesNotPersisted: after MCPDispatch the subject.Scopes slice is unchanged. Keep the v0.1.14 credential-id behaviour. Commit in the plugin repo only.
sm-oauthserver-plugin, Docker, not -short. Use the package Postgres helper. Add to mcp_consent_test.go: TestMCPConsentReplace allows read and write, then allows only read, and the consent row's scopes are only read (not a union). The test must fail if the code calls SaveConsent. TestMCPOfflineAccessUntick: pending offline_access true, POST scopes without that word, the issued MCP code's offline_access column is false; a POST that omits the scopes field leaves the flag as the pending row had it. TestMCPRevokeDeletesCode: issue an MCP code, DELETE /oauth/authorizations/{client_id}, exchanging that code fails, and a second MCP client of the same user still has its token. The revoked event's ClientID is the revoked client and TokenID is zero.
authorizations_kind_test.go TestAuthorizationsKind: an OIDC consent lists kind oidc and an MCP consent lists kind mcp, and the keys are client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind.
If the D-14 file exists, mcp_backfill_test.go TestMCPConsentBackfillIdempotent: a live MCP access token with no consent gets one row whose scopes union the token scopes and include offline_access when the column is true; a pair that already has a consent is left as it was; a second migrate inserts nothing. If the checkpoint in plan 03 held the migration, do not add this test and say that in the summary.
Do not log bearer tokens or authorization codes in test output. Assert status codes and ids.
vue-oauth-layer: pnpm install only if node_modules is missing, then pnpm test. Do not add a dependency.
Framework: go test ./cmd/summer -run TestDocsTree -count=1 and go run ./cmd/summer docs:build --check, then go vet ./... && go test -short ./... . Commit framework test fixes in the framework repo and plugin tests in each plugin repo. Do not git add -A. Do not tag or push in this task.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go test -short -count=1 -run 'TestMCPImplies' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesPersonalUnchanged has no PASS line</fails_when>
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 300s -run 'TestMCPConsentReplace|TestMCPOfflineAccessUntick|TestMCPRevokeDeletesCode|TestAuthorizationsKind|TestMCPConsentBackfillIdempotent' .</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]` for a test this command still names, or a line contains FAIL. If plan 03 held the backfill, `[no tests to run]` is a failure only when TestMCPConsentBackfillIdempotent is still named in the -run list; drop that name and record why.</fails_when>
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
</verify>
<acceptance_criteria>
- TestMCPImpliesPersonalUnchanged, TestMCPImpliesWriteDoesNotGrantRead, TestMCPConsentReplace, TestMCPOfflineAccessUntick, TestMCPRevokeDeletesCode and TestAuthorizationsKind each have a PASS line.
- TestMCPConsentBackfillIdempotent has a PASS line when the migration file exists, and is absent when the migration was held.
- TestDocsTree, docs:build --check, go vet ./... and go test -short ./... pass.
- vue-oauth-layer pnpm test passes.
</acceptance_criteria>
<done>SC-1 through SC-4 have automated proofs beyond the earlier tracer smokes, and no tag has been created.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Whether to tag, push, and post the changelog</name>
<reversibility rating="costly">A pushed framework tag is the release other apps will require. It is not created unless this checkpoint accepts it (D-16).</reversibility>
<decision>Tag and push the framework 0.5 patch, the sm-user-plugin patch, the sm-oauthserver-plugin patch, and the first vue-oauth-layer tag, then post the framework changelog?</decision>
<context>D-16 is patch releases only. The version numbers are computed from existing tags at execution time, never typed in advance. The changelog is the summercms.io post for the framework tag. Plugin repos are pushed before any app pointer bump, and this phase does not bump app pointers. There is no auto_select: an unattended run must stop here. On resume, before the next task changes any ref, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt`. First line is `hold` or `tag-and-publish`. Then four sections in this order: a header line `framework yes` or `framework no`, one local tag name per line, then a line that is only a period, and the same for `user`, `oauth`, and `layer`. The header is yes only when that repo already has a remote named origin. Tag names come from a read-only for-each-ref of refs/tags. Do not create a tag, do not push, and do not publish the changelog in this task.</context>
<options>
<option id="tag-and-publish">
<name>Tag, push, and post the changelog</name>
<pros>The three gaps are on patch tags an application can require. The framework tag gets its changelog post the same day.</pros>
<cons>The tags are public. A mistake is a further patch, not a rewritten tag.</cons>
</option>
<option id="hold">
<name>Do not tag or push</name>
<pros>The code stays local until the user asks again.</pros>
<cons>SC-4's patch tags stay unmet until a later go-ahead. No changelog post.</cons>
</option>
</options>
<resume-signal>Select tag-and-publish or hold</resume-signal>
<read_first>
.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-16
CLAUDE.md section on changelog posts
</read_first>
<acceptance_criteria>
- No git tag, git push, or changelog publish runs before this checkpoint returns.
- hold leaves every repo untagged by this plan.
- `32.1-05-release-decision.txt` exists before the next task changes any ref, and its first line is the accepted option id.
</acceptance_criteria>
</task>
<task type="auto">
<name>Tag and publish only if the checkpoint accepted it</name>
<reversibility rating="costly">Pushed tags and a changelog post are the release. hold does none of this.</reversibility>
<files>../sm-oauthserver-plugin/go.mod, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md</files>
<read_first>
../sm-oauthserver-plugin/go.mod require lines for summercms and sm-user-plugin
CLAUDE.md changelog section (scripts/changelog-post.py, no token printing)
../sm-summercmsio-app/DEPLOY.md section Posting to the changelog, if present
</read_first>
<action>
Read `32.1-05-release-decision.txt`. Do not rewrite it after any ref changes. If you resumed and the file is not on disk yet, write it in the form the checkpoint describes, from the accepted option and a read-only for-each-ref of each repo, before changing any ref, then leave the file unchanged. If the first line is hold, do not create a tag, do not push, and do not run the changelog script. Say that in the summary and stop.
If the accepted option is tag-and-publish (D-16): compute versions from git tag --list. Framework: highest v0.5.* patch, plus one. Never a minor bump and never a version literal written into Go source. sm-user-plugin: highest v0.1.* patch, plus one, on branch phase-32.1-mcp-implied-scopes. sm-oauthserver-plugin: highest v0.1.* patch, plus one. vue-oauth-layer: v0.1.0, the version already in its package.json, only if that tag does not already exist.
Order: commit any uncommitted test files by explicit path in their own repos. Tag the framework. Tag the user plugin. In sm-oauthserver-plugin, set the require versions of summercms and sm-user-plugin to those new tags, commit that go.mod and go.sum change by path, then tag the plugin. Tag vue-oauth-layer. Do not delete or move the existing summercms replace line.
Push each repo that already has origin: the branch that was tagged, then the tag. Do not force-push. If vue-oauth-layer has no origin, leave the local tag and stop before inventing a remote; report that the empty remote git@git.golem15.com:golem15/vue-oauth-layer.git still has to be created by the user. Do not bump any app submodule pointer.
Changelog, only for the framework tag, from ../sm-summercmsio-app: scripts/changelog-post.py source for that tag, then publish `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` with Highlights, Added, Changed, and Fixed. The notes say which framework, sm-user-plugin, sm-oauthserver-plugin and vue-oauth-layer versions close the three gaps, and they may name sm-servers-app as the audience (D-16). Do not put that application name into framework source, READMEs, or docs. No planning IDs, no quick codes, no H1. Do not print, copy, or mint the journal token. If ~/.config/summercms/journal-token is missing or the script refuses it, stop and ask. Do not git add -A. The verify reads the checkpoint record and fails when that record is absent or the local refs, the origin listing, or the changelog post disagree with it. Do not point the verify command at tag creation or at a push.
</action>
<verify>
<automated>python3 -c 'import pathlib, subprocess, sys, urllib.request, urllib.error
root = pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go")
phase = root / ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con"
rec = phase / "32.1-05-release-decision.txt"
notes = phase / "32.1-05-changelog-notes.md"
repos = {"framework": root, "user": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin"), "oauth": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin"), "layer": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer")}
if not rec.is_file():
sys.exit("checkpoint record absent")
lines = [ln.strip() for ln in rec.read_text().splitlines()]
if not lines or lines[0] not in ("hold", "tag-and-publish"):
sys.exit("checkpoint record option is not hold or tag-and-publish")
opt = lines[0]
sections = {}
i = 1
for name in ("framework", "user", "oauth", "layer"):
if i >= len(lines):
sys.exit("checkpoint record missing " + name)
head = lines[i].split()
i += 1
if len(head) != 2 or head[0] != name or head[1] not in ("yes", "no"):
sys.exit("checkpoint record bad header " + name)
tags = []
while i < len(lines) and lines[i] != ".":
if lines[i]:
tags.append(lines[i])
i += 1
if i >= len(lines) or lines[i] != ".":
sys.exit("checkpoint record missing end " + name)
i += 1
sections[name] = tags
def git_out(path, args):
if not path.is_dir():
sys.exit("repo missing " + str(path))
r = subprocess.run(["git", "-C", str(path), *args], capture_output=True, text=True)
if r.returncode != 0:
sys.exit("git failed in " + path.name)
return r.stdout
def current_tags(path):
out = git_out(path, ["for-each-ref", "--format=%(refname:short)", "refs/tags"])
return [ln.strip() for ln in out.splitlines() if ln.strip()]
def has_origin(path):
r = subprocess.run(["git", "-C", str(path), "remote", "get-url", "origin"], capture_output=True, text=True)
return r.returncode == 0
def remote_tags(path):
out = git_out(path, ["ls-remote", "--tags", "origin"])
names = []
for ln in out.splitlines():
parts = ln.split()
if len(parts) < 2:
continue
ref = parts[-1]
if ref.endswith("^{}"):
continue
prefix = "refs/tags/"
if ref.startswith(prefix):
names.append(ref[len(prefix):])
return names
def bump(tags, prefix):
best = None
for t in tags:
if t.startswith(prefix) and t[len(prefix):].isdigit():
n = int(t[len(prefix):])
best = n if best is None else max(best, n)
if best is None:
sys.exit("no baseline tag for " + prefix)
return prefix + str(best + 1)
if opt == "hold":
for name in ("framework", "user", "oauth", "layer"):
if set(current_tags(repos[name])) != set(sections[name]):
sys.exit("hold but local tags differ " + name)
sys.exit(0)
expected = {"framework": {bump(sections["framework"], "v0.5.")}, "user": {bump(sections["user"], "v0.1.")}, "oauth": {bump(sections["oauth"], "v0.1.")}, "layer": set() if "v0.1.0" in sections["layer"] else {"v0.1.0"}}
fw_tag = None
for name in ("framework", "user", "oauth", "layer"):
cur = set(current_tags(repos[name]))
base = set(sections[name])
added = cur - base
removed = base - cur
if removed:
sys.exit("tags removed in " + name)
if added != expected[name]:
sys.exit("tags disagree in " + name)
if has_origin(repos[name]) and not expected[name] <= set(remote_tags(repos[name])):
sys.exit("push missing in " + name)
if name == "framework":
fw_tag = next(iter(expected[name]))
token = pathlib.Path.home() / ".config" / "summercms" / "journal-token"
if not token.is_file():
sys.exit(0)
if fw_tag is None or not str(fw_tag).startswith("v"):
sys.exit("framework tag missing")
ver = fw_tag[1:]
if not notes.is_file():
sys.exit("changelog notes missing")
content = notes.read_text().strip()
if (not content) or content.startswith("# "):
sys.exit("changelog notes empty or start with an H1")
for h in ("## Highlights", "## Added", "## Changed", "## Fixed"):
if h not in content:
sys.exit("changelog notes missing heading")
if ver not in content:
sys.exit("changelog notes missing version")
url = "https://summercms.io/changelog/summercms-" + ver.replace(".", "-")
req = urllib.request.Request(url, headers={"User-Agent": "summercms-plan-verify"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
except Exception:
sys.exit("changelog fetch failed")
if ("SummerCMS " + ver) not in body:
sys.exit("changelog post missing " + ver)'</automated>
<fails_when>non-zero exit. That includes checkpoint record absent, checkpoint record option is not hold or tag-and-publish, checkpoint record missing, checkpoint record bad header, repo missing, git failed, hold but local tags differ, no baseline tag, tags removed, tags disagree, push missing, framework tag missing, changelog notes missing, changelog notes empty or start with an H1, changelog notes missing heading, changelog notes missing version, changelog fetch failed, or changelog post missing.</fails_when>
</verify>
<acceptance_criteria>
- hold: git tag --list in each repo shows no tag created by this task, and the changelog script was not run.
- tag-and-publish: the framework tag is the next v0.5 patch, the two plugins are the next v0.1 patches, vue-oauth-layer is v0.1.0, and each repo that had origin was pushed. The framework changelog post was attempted, or the task stopped because the token file was missing.
- No app submodule pointer moved.
</acceptance_criteria>
<done>D-16 either shipped as patch tags plus the framework changelog, or stayed local because the checkpoint said hold.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test output and changelog notes | Tokens and authorization codes must not be copied into logs or the public post. |
| git push of a release tag | The tag is what other repositories will require. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32.1-13 | Information disclosure | tests and changelog-post.py | medium | mitigate | Assert status codes and ids. Do not print bearer tokens, authorization codes, or the journal token. If the token file is missing, stop. |
| T-32.1-14 | Spoofing | release tags | low | accept | Tagging does not change PKCE, redirect matching, or the read/write ceiling. The checkpoint is the control on publishing, not a new auth path. |
</threat_model>
<verification>
The named tests PASS, docs:build --check passes, and no tag exists unless tag-and-publish was accepted.
</verification>
<success_criteria>
- SC-1 through SC-4 are covered by the tests named in this plan.
- Tags, pushes, and the changelog post are behind the checkpoint.
</success_criteria>
<output>
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md` when done.
</output>
## Artifacts this phase produces
- /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin
- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer
Tests added:
- TestMCPCallFromBareContext
- TestFrontendTokenScopeLiteralWithoutImplies
- TestPRMUsesAdvertisedScopes
- TestPRMFallsBackToPRMScopes
- TestDispatchMCPCallBothGuards
- TestMCPImpliesPersonalUnchanged
- TestMCPImpliesWriteDoesNotGrantRead
- TestMCPImpliesNotPersisted
- TestMCPConsentReplace
- TestMCPOfflineAccessUntick
- TestMCPRevokeDeletesCode
- TestAuthorizationsKind
- TestMCPConsentBackfillIdempotent when the D-14 migration file exists
Release, only after tag-and-publish: next framework v0.5 patch tag, next sm-user-plugin v0.1 patch tag, next sm-oauthserver-plugin v0.1 patch tag, vue-oauth-layer v0.1.0, and the framework changelog post.

View File

@@ -0,0 +1,134 @@
# Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent - Context
**Gathered:** 2026-10-28
**Status:** Ready for planning
<domain>
## Phase Boundary
An agent connected to `/mcp` over OAuth gets the same tools, attribution and revocation as one using a member personal API token, so that a consuming application can switch OAuth sign-in on in production. Three additive gaps are closed:
1. **Declared scopes over MCP OAuth.** Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted on the MCP OAuth server, and appear in pier's protected-resource metadata. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (+ `backend` on the MCP server).
2. **MCP call context.** A route run by an MCP tool call can read who called it (guard, user, client ID, token id, scopes) and the MCP session through a `pact` accessor.
3. **MCP consent row.** `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions.
Everything ships as patch releases: framework 0.5.x, `sm-oauthserver-plugin`, and `sm-user-plugin` (it publishes the scope catalog and applies the implied scopes). Module READMEs and `docs/` pages describe the changes. The phase also starts a public Nuxt layer, `vue-oauth-layer`, with the `/connect` consent page (D-17).
The three gaps come from sm-servers-app phase 05 (`/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-CONTEXT.md`, D-36 to D-38), escalated as upstream Requests 3, 4 and 5 on 2026-10-28. That app keeps `golem15.oauthserver.mcp.enabled` off in production until they ship. Its expected-gap tests (`TestMCPOAuthToolsMatchPersonal`, `TestMCPOAuthWriteAttribution`) must turn into passing assertions against the released versions without app-side scope middleware.
</domain>
<decisions>
## Implementation Decisions
### Declared scopes and the read/write ceiling
- **D-01:** **The 19.7 D-20 ceiling stays.** OAuth `read`/`write` remain the ceiling: a non-read-only tool still needs `write`, and a route's `user.scope:<declared>` still needs the declared scope in the grant. A write tool behind `user.scope:bm:write` needs both `write` and `bm:write`, as a personal token with read/write plus declared scopes does today. No pier ceiling change.
- **D-01a:** **Declared scopes can be implied by stock `read`/`write` (Request 3).** `pact.FrontendTokenScope` gets an additive field (proposed `Implies`, value `"read"` or `"write"`, empty = not implied). An OAuth MCP grant holding the stock scope also satisfies every declared scope that names it as implied. For example, `servers:read` with `Implies: "read"` is satisfied by a `read` grant, so an agent granted `read write` lists the same servers tools as a personal token with `servers:read servers:write` (sm-servers-app's "OAuth list equals personal list"). The expansion applies only to OAuth MCP subjects, in sm-user-plugin's MCP guard (`MCPAllows`/`MCPDispatch`) or where the subject's scopes are built. A personal token's scopes keep their meaning, and the user still never gets a scope the plugin did not mark as implied. Declared scopes are still advertised (D-03) for clients that want a narrower grant. — **Reversibility:** costly — once apps rely on read/write implying their scopes, removing it shrinks live agents' tool lists.
- **D-02:** **Declared scopes are offered on the MCP OAuth server only**, like `backend` (19.7 D-21). The OIDC server keeps its PHP-parity scope list.
- **D-03:** **Advertised scopes = fixed scopes + declared scopes, filtered by an optional opt-in list.** When the config list is set, only the declared scopes it names are advertised and accepted. When it is unset, every declared scope is. The fixed scopes (`read`, `write`, `ai`, `offline_access`, `backend`) are always present. The same set drives the wristband MCP server's metadata `scopes_supported`, its authorize allow-list (`allowedAuthorizeScopes`) and pier's PRM `scopes_supported`. This rule must be documented clearly in the oauthserver README, the pier README and `docs/services/oauth-server.md` / `docs/services/mcp.md`. A name in the list that no plugin declares should be a clear boot error or warning; the planner decides which. — **Reversibility:** costly — scope names published in metadata and stored in grants.
- **D-04:** **The config key lives in sm-oauthserver-plugin** (proposed `golem15.oauthserver.mcp_scopes`), next to where `NewServers` builds `mcpOpts.ScopesSupported`. **pier reads the list from the published verifier** (`pact.MCPTokenVerifier`, extended additively) instead of its own `PRMScopes` constant, so the PRM and the authorization server cannot drift. `pier.PRMScopes` stays as the fallback when no verifier publishes a list.
- **D-05:** **Plumbing: a published pact catalog.** The plugin that owns member tokens (sm-user-plugin, which already compiles and validates `FrontendTokenScopes()` at boot) publishes the compiled list on the app (`app.Lookup[...]`) through a new additive pact interface. sm-oauthserver-plugin and pier read it at boot. Labels and descriptions travel with the names so the consent page can show them.
### Consent page
- **D-06:** **Per-scope checkboxes.** This changes the consent behaviour from 19.4's allow/deny-the-whole-request. Every requested scope starts ticked. `read`, when requested, is locked (cannot be unticked). `offline_access`, `backend` and declared scopes can be unticked. A submit that leaves no data-bearing scope keeps the existing 422 `no_grantable_scopes`. `MCPConsentDecide` already intersects submitted ∩ requested (`grantedScopes`), so the server side mostly exists.
- **D-07:** **Both surfaces.** The MCP consent JSON (show answer of `controllers/mcp_consent_api.go`) carries a label and description per requested scope: the FrontendTokenScope label/description resolved through phrasebook for declared scopes, existing text for fixed scopes. It also says whether each scope is locked, so redirect-mode app pages can render checkboxes or keep all-or-nothing (as the consuming app chose in its D-07). The built-in consent page renders the checkboxes. *Research must confirm which built-in page the MCP flow uses today* (`views/consent.html.tmpl` vs redirect-only to an app `/connect` page). If the MCP flow has no built-in page, the checkboxes go wherever the plugin ships its MCP consent rendering, and the planner raises it.
- **D-08:** **Re-consent on scope growth.** When a client that already holds a consent requests any scope the recorded consent does not cover, the user goes through the consent page again. After the decision, the stored consent's scopes become the newly granted set.
### MCP call context accessor
- **D-09:** **The accessor lives in `pact`:** `pact.MCPCallFrom(ctx) (MCPCall, bool)` with a matching setter that pier calls next to `bouncer.WithDispatch` in `dispatch.go`. Plugins already import pact, so they need no pier or bouncer import.
- **D-10:** **New `pact.MCPCall` wrapper:** `MCPCall{Subject MCPSubject, SessionID string, Tool string}`. `MCPSubject` (the guard-facing type) stays unchanged. `SessionID` is the MCP session id and is empty in pier's stateless mode. `Tool` is the tool name. Exact field names are the planner's call within this shape. — **Reversibility:** costly — exported pact API that consuming apps will read.
- **D-11:** **Every MCP tool call carries it:** OAuth and personal-token, frontend and backend guards. The subject is the one pier resolved for that tool's guard (`id.subjectFor(t.guardName)`). Plain HTTP requests have none (`ok == false`). A personal-token call's credential keeps carrying the token id as today (sm-user-plugin v0.1.14); the accessor is additive to it, not a replacement.
### MCP consent row and revoke
- **D-12:** **The same `golem15_oauthserver_consents` table.** When an MCP code is issued (`MCPConsentDecide` → `IssueCode`, and any direct grant path through the MCP store), upsert the (user_id, client_id) row with the granted scopes. MCP clients are DCR clients distinct from OIDC clients, so the unique (user_id, client_id) constraint does not collide. The existing `revoke` (deletes the consent, plus codes, access and refresh tokens of the pair, then emits `MCPSubjectRevoked{ClientID}`, which pier uses to evict sessions) then works unchanged for MCP clients.
- **D-13:** **MCP clients appear in `GET /oauth/authorizations`** with an additive kind marker (e.g. `"kind":"mcp"`; OIDC entries get `"oidc"` or omit it, per the planner). Existing fields and order are unchanged.
- **D-14:** **A data migration backfills consents** for grants that predate this release: one row per (user, client) with live MCP access or refresh tokens, scopes = union of the tokens' scopes, `created_at` = earliest token. It must be idempotent and skip pairs that already have a consent. — **Reversibility:** one-way — writes rows into a shared table on every consuming install.
- **D-15:** **One revoke ends everything for that client:** frontend and backend (`backend_user_id`) access and every MCP session of the client. No separate backend revoke.
### Consent page as a Nuxt layer
- **D-17:** **An initial `vue-oauth-layer` ships in this phase.** It is a new public Nuxt layer repo next to `vue-journal-layer`, `vue-faq-layer`, `vue-menu-layer` and `vue-paymentgateway-layer`, with the same structure and the same pnpm-git-dependency-pinned-by-tag consumption. It provides the `/connect` page of redirect consent mode, rendered from the MCP consent JSON (D-07): client name and logo, per-scope checkboxes with labels and descriptions, `read` locked, the "also act as admin <name>" line for `backend`, allow/deny, and the `backend_link_changed` 409 re-show. Apps adopt it later as their own work, so 32.1 does not migrate any app's existing `/connect` page. The layer gets a tag. It is one plan in the phase.
### Release
- **D-16:** Patch releases only: framework 0.5.x (never hardcode the version), `sm-user-plugin` (catalog publication, implied scopes), `sm-oauthserver-plugin` (scopes, consent JSON/page, consent row, backfill, listing marker), plus the first tag of `vue-oauth-layer`. Commit and push each plugin before bumping pointers in apps. Each pushed framework tag gets a changelog post. The release notes tell sm-servers-app which versions close Requests 3–5.
### Claude's Discretion
- The pact interface name and shape for the published scope catalog (D-05), within "names + label + description, additive".
- Whether an opt-in name nobody declares fails boot or warns (D-03).
- Field names of `MCPCall` and the setter name (D-10).
- How the kind marker is spelled in the authorizations listing (D-13).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope
- `.planning/ROADMAP.md` § "Phase 32.1" — goal and the four success criteria.
### Prior decisions this phase builds on
- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md` — pier design; D-10 (tool binds to route, in-process dispatch), D-16 (permissions from route middleware), D-20 (read/write ceiling, kept by D-01), D-21 (`backend` scope on the MCP server only).
- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md` — sm-oauthserver-plugin's two-Server design, client kinds, consent model, additive wristband interfaces.
- `/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-CONTEXT.md` § "Decisions after gap verification (2026-10-28)" — D-36/D-37/D-38, the source of this phase (Requests 3, 4, 5).
- `/media/nvme/dev/pxpx/sm-servers-app/.planning/phases/05-agent-access-over-mcp/05-09-PLAN.md` — the expected-gap tests the releases must satisfy (`TestMCPOAuthToolsMatchPersonal`, `TestMCPOAuthWriteAttribution`, `TestMCPOAuthEmptyGrant`) and the rejected app-side scope middleware.
- `/media/nvme/dev/pxpx/sm-servers-app/OFFBOARDING.md` § 8 — the request texts (what, why, interim).
- `../vue-journal-layer/` (and the faq/menu/paymentgateway layers) — the layer structure and release pattern to copy for `vue-oauth-layer`.
- `../sm-bm-app/.planning/phases/03.3-mcp-access-for-bm/03.3-CONTEXT.md` — a consuming app's MCP access decisions (D-07 consent page all-or-nothing in that app; personal tokens alongside OAuth).
### Docs to update
- `modules/pier/README.md`, `modules/wristband/README.md`, `modules/pact/README.md`
- `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md`
- `../sm-oauthserver-plugin/README.md`, the sm-user-plugin README
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `modules/pact/capabilities.go:821-835`: `FrontendTokenScope{Name, Label, Description}` and `HasFrontendTokenScopes`; `ValidTokenScopeName`.
- `modules/pact/mcp.go:134`: `MCPSubject{Guard, UserID, Scopes, ClientID, TokenID}`, wrapped by the new `MCPCall`.
- `modules/pact/mcp.go:289-303`: `MCPSubjectChanged` with `MCPSubjectRevoked` + `ClientID`, which pier already uses to evict one client's sessions (`modules/pier/events.go:75-90`).
- `../sm-oauthserver-plugin/controllers/mcp_consent_api.go`: `MCPConsentDecide` and `grantedScopes` (submitted ∩ requested) already support partial grants.
- `../sm-oauthserver-plugin/controllers/authorizations.go`: `AuthorizationsIndex` and `revoke(userID, clientID)` (consent + codes + access/refresh tokens in one tx, then the revoked event).
### Established Patterns
- Scope lists are hardcoded today: `modules/wristband/server.go:99` (`ScopesSupported`), `modules/wristband/authorize.go:24` (`authorizeAllowedScopes`, used only when `Options.ScopesSupported` is empty), `modules/pier/prm.go:14` (`PRMScopes`).
- `../sm-oauthserver-plugin/servers.go:158` builds the MCP server's scopes as OIDC scopes + `backend`; the declared/opt-in set goes in here.
- `modules/pier/filter.go:60-70`: the read/write ceiling (`id.writes()`) before the guard's `MCPAllows`; unchanged by D-01.
- sm-user-plugin `classes/mcp_guard.go`: `MCPAllows` requires the route's `user.scope` names in `subject.Scopes`; the personal-token `MCPDispatch` builds an in-memory credential carrying the token.
- Find-or-publish of shared registries on `backpack.App` (`pact.MCPGuards`) is the model for the published scope catalog.
### Integration Points
- `modules/pier/dispatch.go:177-200`: where `bouncer.WithDispatch` is set; the `MCPCall` setter goes next to it (session id from the call info, tool from `t.name`).
- `modules/pier/prm.go:mountPRM`: already looks up `pact.MCPTokenVerifier`; extend it to read the scope list from there.
- `../sm-oauthserver-plugin/classes/mcp_store.go`: the MCP grant/token store; the consent upsert hooks in at code issue (and at direct grant minting).
- `../sm-oauthserver-plugin/updates/`: new migration for the backfill (D-14).
</code_context>
<specifics>
## Specific Ideas
- The opt-in-or-all rule (D-03) must be documented with examples: unset = all declared scopes, set = only those listed, fixed scopes always.
- A redirect-mode app may keep an all-or-nothing consent page; the JSON must give it enough (label, description, locked) to render either way.
</specifics>
<deferred>
## Deferred Ideas
- Offering declared scopes on the OIDC server (non-MCP OAuth clients). Rejected for this phase (D-02).
- Release/rollout order across framework, sm-user-plugin, sm-oauthserver-plugin and the consuming app was not discussed in detail; the planner follows the standard "plugin first, pointer bump second" order.
- Moving apps' existing `/connect` pages (sm-servers-app, sm-bm-app, sm-demo-app) onto `vue-oauth-layer`: each app's own work.
- Switching OAuth on in sm-servers-app production: that app's decision once the releases land.
### Reviewed Todos (not folded)
- 27 pending todos matched on keywords only (e.g. "Record PHP OIDC parity against WavePath", user-plugin logout cookie, activate-by-code second factor). None touch MCP scopes, call context or MCP consent; none were folded.
</deferred>
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*

View File

@@ -0,0 +1,143 @@
# Phase 32.1: MCP OAuth for plugin apps - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-10-28
**Phase:** 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
**Areas discussed:** Declared scopes vs ceiling, Consent page for scopes, MCP subject accessor, MCP consent row & revoke
---
## Declared scopes vs ceiling
| Option | Description | Selected |
|--------|-------------|----------|
| Keep both | D-20 read/write ceiling stays; declared scopes are extra gates | ✓ |
| Declared scope stands alone | Tools behind a declared scope skip the read/write ceiling | |
| Option | Description | Selected |
|--------|-------------|----------|
| Fixed + all declared | Every FrontendTokenScope is advertised | |
| Fixed + config opt-in list | Only listed declared scopes advertised | |
| Other | | ✓ |
**User's choice:** Opt-in config list if set; if unset, all declared scopes. "Document this properly."
| Option | Description | Selected |
|--------|-------------|----------|
| MCP server only | Same as `backend` (D-21) | ✓ |
| Both servers | OIDC clients could request plugin scopes too | |
| Option | Description | Selected |
|--------|-------------|----------|
| You decide | Researcher/planner pick the mechanism | |
| Published pact catalog | User plugin publishes the compiled list; oauthserver and pier read it | ✓ |
---
## Consent page for scopes
| Option | Description | Selected |
|--------|-------------|----------|
| oauthserver, pier reads it | `golem15.oauthserver.mcp_scopes`; pier reads from the verifier | ✓ |
| pier (mcp.scopes) | MCP module owns the list | |
| Option | Description | Selected |
|--------|-------------|----------|
| Label+description, all-or-nothing | As in 19.4 | |
| Per-scope checkboxes | User can untick individual scopes | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Both: JSON + built-in page | Consent JSON carries label/description; built-in page renders them | ✓ |
| JSON only | Built-in page shows raw names | |
| Option | Description | Selected |
|--------|-------------|----------|
| Show consent again | Any uncovered requested scope sends the user through consent | ✓ |
| You decide | Follow the OIDC path | |
| Option | Description | Selected |
|--------|-------------|----------|
| All ticked, read locked | `read` can't be unticked; others can | ✓ |
| All ticked, none locked | Anything can be unticked | |
---
## MCP subject accessor
| Option | Description | Selected |
|--------|-------------|----------|
| pact | `pact.MCPSubjectFrom(ctx)` | ✓ |
| pier | `pier.SubjectFrom(ctx)` | |
| bouncer | Subject field on `bouncer.Dispatch` | |
| Option | Description | Selected |
|--------|-------------|----------|
| Every MCP tool call | OAuth + PAT, frontend + backend | ✓ |
| OAuth calls only | | |
| Option | Description | Selected |
|--------|-------------|----------|
| MCPSubject as is | Guard, UserID, ClientID, TokenID, Scopes | |
| Plus the MCP session id | Also Mcp-Session-Id | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| New pact.MCPCall wrapper | `MCPCall{Subject, SessionID, Tool}`; MCPSubject unchanged | ✓ |
| SessionID field on MCPSubject | Guards see the field too | |
---
## MCP consent row & revoke
| Option | Description | Selected |
|--------|-------------|----------|
| Same consents table | Upsert (user, client) at MCP code issue | ✓ |
| Separate MCP consents table | New table, revoke/listing look in both | |
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, with a kind marker | Additive `"kind":"mcp"` | ✓ |
| Yes, no new field | | |
| Separate MCP list endpoint | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Migration backfills rows | From live MCP access/refresh tokens | ✓ |
| Lazy: write on next refresh | | |
| Nothing | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, one revoke ends all | Frontend + backend + sessions | ✓ |
| You decide | | |
---
## Claude's Discretion
- Pact catalog interface name/shape; boot error vs warning for unknown opt-in names; `MCPCall` field and setter names; kind marker spelling.
## Deferred Ideas
- Declared scopes on the OIDC server.
- Detailed rollout order across repos (planner uses the standard plugin-first order).
---
## Follow-up (same day): source notes and Vue layer
The source notes were found in `/media/nvme/dev/pxpx/sm-servers-app` phase 05 (D-36 to D-38, Requests 3–5). Request 3 asked that OAuth `read`/`write` map onto declared scopes, which differs from the locked "advertise declared scopes" approach.
| Option | Description | Selected |
|--------|-------------|----------|
| Both: declared implies + advertise | Additive `Implies` on FrontendTokenScope; declared scopes still advertised | ✓ |
| Mapping only | Exactly Request 3; drop advertising | |
| Advertise only (as locked) | Clients must request servers:* explicitly | |
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, initial layer in 32.1 | New public vue-oauth-layer with the /connect page | ✓ |
| Defer to its own phase | JSON contract only | |

View File

@@ -0,0 +1,358 @@
# Phase 32.1: MCP OAuth for plugin apps — Pattern Map
**Mapped:** 2026-10-28
**Files analyzed:** 28
**Analogs found:** 27 / 28 (the new `vue-oauth-layer` repo has no in-repo file; copy the sibling `vue-menu-layer` git repo)
Tracked-source check: framework paths are `git ls-files` in this repo. Plugin paths are `git ls-files` inside `sm-oauthserver-plugin` (`df2eddd`, v0.1.3) and the `sm-user-plugin` submodule at `sm-demo-app/plugins/golem15/user` (`07196a4`, **v0.1.13**). That submodule is the only local checkout; **implement the user plugin from tag v0.1.14 (`a1b78b80`), not from this worktree.** `vue-menu-layer` is its own repo (`ef3a090`). Do not edit app submodule pointers.
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `modules/pact/capabilities.go` (`FrontendTokenScope.Implies`) | model | transform | same file, `FrontendTokenScope` / `TokenScope` | exact (in-place) |
| `modules/pact/mcp.go` (`MCPCall`, `WithMCPCall`, `MCPCallFrom`, `MCPAdvertisedScopes`) | model | request-response | `modules/bouncer/dispatch.go` + `modules/pact/mcp.go` `MCPTokenVerifier` | exact |
| `modules/pier/dispatch.go` | service | request-response | same file, `bouncer.WithDispatch` site | exact (in-place) |
| `modules/pier/prm.go` | service | request-response | same file, `mountPRM` | exact (in-place) |
| `modules/wristband/consent.go` (`PendingRequestView.OfflineAccess`) | model | CRUD | same file, `PendingRequestView` | exact (in-place) |
| `modules/{pact,pier,wristband}/README.md`, `docs/services/{mcp,oauth-server,authentication}.md` | config | transform | the same pages | exact (in-place) |
| pact/pier unit tests (new cases) | test | request-response | `modules/wristband/mcp_scopes_test.go`, existing pier tests | role-match |
| sm-user-plugin `classes/api_token_scopes.go` | service | transform | same file, `CompileAPITokenScopes` | exact (in-place; branch from v0.1.14) |
| sm-user-plugin `classes/mcp_guard.go` | middleware | request-response | same file, `MCPAllows` / `MCPDispatch` | exact (in-place; v0.1.14 body) |
| sm-user-plugin `plugin.go` `bootAPITokenScopes` | provider | event-driven (boot) | same function + `app.Publish[pact.MCPPersonalTokenResolver]` | exact |
| sm-user-plugin `README.md` | config | transform | same README | exact |
| sm-user-plugin `classes/mcp_guard_test.go` (extend) | test | request-response | existing `TestMCP*` in that package | role-match |
| sm-oauthserver-plugin `config/config.yaml` (`mcp_scopes`) | config | file-I/O | same file header + `modules/pier/config.go` `originsKey` | role-match |
| sm-oauthserver-plugin `servers.go` `NewServers` | service | transform | same function | exact (in-place) |
| sm-oauthserver-plugin `classes/mcp_verifier.go` | service | request-response | same type; optional method like `methodAllower` | role-match |
| sm-oauthserver-plugin `controllers/mcp_consent_api.go` | controller | request-response | same file | exact (in-place) |
| sm-oauthserver-plugin `controllers/authorizations.go` | controller | CRUD | same file `revoke` | exact (in-place) |
| sm-oauthserver-plugin `updates/<new backfill>.go` | migration | batch | `updates/202610230001_add_backend_user_id.go` | exact |
| sm-oauthserver-plugin `lang/en/lang.yaml`, `lang/pl/lang.yaml` | config | file-I/O | existing oauthserver lang files | exact |
| sm-oauthserver-plugin `README.md` | config | transform | same README | exact |
| sm-oauthserver-plugin consent/authorizations/backfill tests | test | CRUD | `mcp_consent_smoke_test.go`, `authorizations_smoke_test.go` | exact (extend) |
| `vue-oauth-layer` (`package.json`, `nuxt.config.ts`, `app/pages/connect.vue`, `tests/*.test.ts`, README) | component | request-response | `vue-menu-layer` (sibling repo) | role-match |
## Pattern Assignments
### `modules/pact/capabilities.go` (model, transform)
**Analog:** `modules/pact/capabilities.go` (tracked)
**Core pattern** (lines 814–834) — add `Implies string` only. Zero value keeps existing composite literals valid. Update the comment that says the struct carries no permission patterns.
```go
type FrontendTokenScope struct {
Name string
Label string
Description string
}
type HasFrontendTokenScopes interface {
FrontendTokenScopes() []FrontendTokenScope
}
```
Do not add a method named `FrontendTokenScopes` on the new catalog interface (that name is already `HasFrontendTokenScopes`).
---
### `modules/pact/mcp.go` (model, request-response)
**Analogs:** `modules/bouncer/dispatch.go` (context key) and `modules/pact/mcp.go` (interfaces already here).
**Context-key pattern** (`modules/bouncer/dispatch.go` lines 5–34). Copy the unexported key. Prefer not turning a nil ctx into `context.Background()`: pier's dispatch context is never nil. `MCPCallFrom` returns `ok == false` when the value is absent (plain HTTP).
```go
type dispatchKey struct{}
func WithDispatch(ctx context.Context, d *Dispatch) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, dispatchKey{}, d)
}
```
**Optional interface, not a new method on `MCPTokenVerifier`** (`modules/pact/mcp.go` lines 217–225). A new method would break every fake. Match the second-interface pattern in `modules/pier/backend_guard.go` lines 29–31:
```go
type methodAllower interface {
MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error)
}
```
Recommended additive types (discretion, already in RESEARCH):
```go
type MemberTokenScopeCatalog interface {
MemberTokenScopes() []FrontendTokenScope
}
type MCPAdvertisedScopes interface {
MCPScopesSupported() []string
}
type MCPCall struct {
Subject MCPSubject
SessionID string
Tool string
}
```
`MCPSubject` stays lines 134–140 (`Guard`, `UserID`, `Scopes`, `ClientID`, `TokenID`). Publish lookup shape is `modules/backpack/app.go` lines 59–73: `Publish[T]` / `Lookup[T]`.
---
### `modules/pier/dispatch.go` (service, request-response)
**Analog:** same file, lines 169–208.
Set the call on the line immediately before `WithDispatch`. `subject` is `id.subjectFor(t.guardName)` (line 169). `Tool` is `t.name`. `SessionID` comes from the call info's session, forced to `""` on the stateless path and never the audit word `"ephemeral"`.
```go
subject := id.subjectFor(t.guardName)
// ...
credential := disp.Credential
if credential == nil && t.guardName == BackendGuard {
credential = id.credential
}
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
```
Do not expand scopes here. Ceiling stays in `modules/pier/filter.go` (`id.writes()` before `MCPAllows`).
---
### `modules/pier/prm.go` (service, request-response)
**Analog:** same file, lines 12–40.
```go
var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// ...
if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil {
if res := verifier.MCPResource(); res != "" { /* issuer */ }
}
meta := &oauthex.ProtectedResourceMetadata{
Resource: p.cfg.Resource,
ScopesSupported: append([]string(nil), PRMScopes...),
BearerMethodsSupported: []string{"header"},
}
```
Replace only the `ScopesSupported` assignment: if the same verifier also implements `pact.MCPAdvertisedScopes` and the slice is non-empty, clone that; otherwise keep `PRMScopes`. Do not import the oauthserver plugin. Do not read the raw member-token catalog (that skips the opt-in filter).
---
### `modules/wristband/consent.go` (model, CRUD)
**Analog:** same file, lines 35–74. Add `OfflineAccess bool` to `PendingRequestView` and fill it in `PendingRequest`. Do not change `IssueCode`'s signature. The store reads a context value (same idea as `classes.WithConsentedBackend`) so a direct `IssueCode` call keeps today's flag.
```go
type PendingRequestView struct {
ClientName string
RedirectHost string
ScopesRequested []string
ExpiresAt time.Time
}
func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error)
```
Authorize allow-list is already `Options.ScopesSupported` when non-empty (`modules/wristband/authorize.go` `allowedAuthorizeScopes`). No wristband change is required to accept a declared name once the host fills the slice. An empty slice falls back to the PHP four-name map and drops `backend` — the host must never pass an empty slice.
---
### sm-user-plugin `classes/api_token_scopes.go` + `plugin.go` (service + boot provider)
**Analog (structure only):** `sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` lines 52–127 and `plugin.go` lines 154–195. **Branch from v0.1.14.** Do not edit the submodule.
`CompileAPITokenScopes` already rejects built-in names `read`/`write`/`ai`, duplicates, empty label/description, and fails boot. Extend that same loop: reject `Implies` other than `""`, `"read"`, `"write"`, and reject declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. Phrase-key check stays in `bootAPITokenScopes` (`tr.Has`).
Publish the compiled list as the new pact interface, beside the existing concrete publish and the guard publish:
```go
return app.Publish(catalog) // today: *APITokenScopeCatalog
// and:
return app.Publish[pact.MCPPersonalTokenResolver](guard)
```
The published catalog includes built-ins (labels for consent) plus declared scopes. Advertised declared names are catalog entries whose name is not `read`, `write`, or `ai`. `offline_access` and `backend` are not member-token scopes; oauthserver prepends the fixed five.
---
### sm-user-plugin `classes/mcp_guard.go` (middleware, request-response)
**Analog:** v0.1.13 worktree `MCPAllows` lines 87–136 is the wrong credential body. On **v0.1.14** `MCPDispatch` sets `credential.ID = subject.TokenID` only when `subject.ClientID == ""` and leaves OAuth credentials at ID 0. Keep that. One helper used by both `MCPAllows` and `MCPDispatch`:
- `ClientID == ""`: return `subject.Scopes` unchanged (personal token).
- else: clone scopes; for each catalog entry with `Implies` `"read"` or `"write"`, if the grant contains that stock scope and the declared name is absent, append it.
- Never write the expanded set onto the token row, the session fingerprint, or `subject.Scopes` as stored.
`MCPAllows` today (v0.1.13 lines 90–94), which the helper replaces for the scope check only:
```go
for _, scope := range req.Scopes {
if !slices.Contains(subject.Scopes, scope) {
return false, nil
}
}
```
`MCPDispatch` puts the helper's slice on `models.APIToken.Scopes` (`lagoon.Jsonable`). `middleware.Scope` reads `token.HasScope` (`slices.Contains` on that slice). Do not set `APIToken.OAuthClientID`. The OAuth client id belongs on `pact.MCPCall`. Do not expand scopes in pier's backend guard (`modules/pier/backend_guard.go`).
---
### sm-oauthserver-plugin `servers.go` + `config/config.yaml` (service + config)
**Analog:** `servers.go` lines 154–158 and `modules/pier/config.go` `originsKey` lines 230–262.
```go
mcpOpts := opts
mcpOpts.Resource = cfg.MCPResource
mcpOpts.ScopesSupported = append(slices.Clone(opts.ScopesSupported), "backend")
```
`mcpOpts := opts` is already a struct copy. Build the MCP slice on that copy only: fixed five (`read`, `write`, `ai`, `offline_access`, `backend`), then declared names in catalog order, filtered by `golem15.oauthserver.mcp_scopes` when `Config.Has` is true. Empty list (key set) means fixed five only. Unset means every declared scope. Do not assign `cfg.OIDC.ScopesSupported`. Do not append `"backend"` twice. Unknown opt-in name fails boot (name the key and the name), matching `CompileAPITokenScopes`.
Read the list with `Lookup` and the `[]any` / `[]string` / comma-string switch from `originsKey` (env `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES`). Config file header documents the env spelling (`config/config.yaml` lines 1–3).
`classes/mcp_verifier.go` returns `pact.MCPGrant` (lines 109–118) and must also implement `MCPScopesSupported()` with the same slice `NewServers` assigned. Publish both `pact.MCPTokenVerifier` and the optional scopes interface on that value.
---
### sm-oauthserver-plugin `controllers/mcp_consent_api.go` (controller, request-response)
**Analog:** same file. Keep `scopes []string`. Add `scope_details` (`name`, `label`, `description`, `locked`) and `logo_url` on the client. `locked` is true only for requested `read`.
Show body today (lines 24–43):
```go
type mcpConsentShowBody struct {
RequestID string `json:"request_id"`
Client mcpConsentClient `json:"client"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
BackendAdmin *mcpConsentAdmin `json:"backend_admin"`
}
```
Keep `grantedScopes` (lines 168–180). Do not force `read` back in (smoke test posts `scopes: ["backend"]` and expects 422). Augment the requested list with `offline_access` when the pending flag is set, intersect against that, strip the word before `IssueCode`, and set the column from the decision via context. A submit whose only remaining scope is `offline_access` still 422. `read` is data-bearing. `backend` counts only when a link is recorded (existing strip at lines 144–161).
Labels: catalog phrase through `phrasebook.Translator.Get` when the text contains `::`, else the literal. `backend` and `offline_access` are not in the user catalog; add en+pl phrase keys in this plugin. Logo: `classes.ClientLogoURL` (already used in `authorizations.go` line 81).
**Do not call `oidcTx.SaveConsent`.** It unions ( `classes/oidc_store.go` lines 328–361). MCP decision replaces scopes (`ON CONFLICT DO UPDATE SET scopes = excluded.scopes`). Silent re-grant (`consent_required: false`, nothing missing) only `TouchConsent` (lines 364–366) and issues the requested subset. First grant inserts. Deny does not change the row. Upsert after `IssueCode` returns nil. Store `offline_access` in the consent scope list when the flag was granted. `models.ClientKindMCP` / `ClientKindOIDC` are `"mcp"` / `"oidc"` (`models/client.go` lines 14–17).
---
### sm-oauthserver-plugin `controllers/authorizations.go` (controller, CRUD)
**Analog:** same file, lines 21–157.
Add `Kind string \`json:"kind"\`` last on `authorization`. Select `c.kind`. Always set it (`oidc` or `mcp`), do not omit OIDC. Key order stays `client_id`, `name`, `logo_url`, `scopes`, `first_authorized_at`, `last_used_at`, then `kind`. Update `authorizations_smoke_test.go` which requires exactly six keys.
`revoke` (lines 134–157) locks the consent row then deletes `Consent`, `AuthCode`, `AccessToken`, `RefreshToken`. Also delete `models.MCPCode` for the same pair. Take `classes.lockUserGrants` (exclusive, `classes/mcp_backend_binding.go` lines 61–75) inside the transaction **before** the deletes. Keep 404 `errNotFound` when no consent. Keep the existing `MCPSubjectRevoked` with `ClientID` and **no** `TokenID` (lines 124–126). Do not add a second backend revoke: access/refresh rows already carry `backend_user_id`.
---
### sm-oauthserver-plugin backfill migration (migration, batch)
**Analog:** `updates/202610230001_add_backend_user_id.go` lines 15–35 (`init() { Register(...) }`, ID sorts after `202610230001_add_golem15_oauthserver_backend_user_id`).
```go
var addBackendUserID = []*gormigrate.Migration{{
ID: "202610230001_add_golem15_oauthserver_backend_user_id",
Migrate: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) },
Rollback: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) },
}}
func init() { Register(addBackendUserID...) }
```
A Go loop inside `Migrate` is the practical way to union JSON scope arrays (`models.Strings` is `lagoon.Jsonable[[]string]`). One row per live MCP `(user_id, client_id)`; skip pairs that already have a consent; include `offline_access` from the boolean column; `created_at` = earliest live token; `last_used_at` stays null; second run inserts nothing. `Rollback` no-ops with a comment (D-14 is one-way). Do not delete every MCP consent on rollback.
---
### Docs (config, transform)
**Analogs:** the pages themselves. Identifiers and the code that exports them land in the same commit (`go test ./cmd/summer -run TestDocsTree`). Framework docs never name a consuming app. Document:
- `pact.MCPCall` / `MCPCallFrom` / setter: `Subject.TokenID` is a `user_api_tokens` id only when `ClientID == ""`; otherwise it is the oauthserver access-token row. `Subject.Scopes` is the grant, not the implied expansion.
- `golem15.oauthserver.mcp_scopes`: unset = all declared, set = only those names, empty list = fixed five only, fixed five always. Config keys are not checked by the docs checker; review them by hand.
- pier PRM reads the verifier list, else `PRMScopes`.
- Existing DCR clients with a non-nil scope ceiling silently drop names outside that ceiling. Do not rewrite old ceilings.
Do not add checkboxes to `views/consent.html.tmpl` (OIDC page). MCP authorize always redirects to `{issuer}/connect?request=`.
---
### `vue-oauth-layer` (component, request-response) — no file in this repo
**Analog:** sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer` (git `ef3a090`), not `vue-journal-layer` (journal pulls SEO/i18n/fonts).
**package.json** (lines 1–24): `"type": "module"`, `"main": "./nuxt.config.ts"`, `"engines": {"node": ">=22.18"}`, peer `nuxt` 4.4.8, dev `vue` 3.5.35 and `typescript` 5.9.3, `"test": "node --test --test-reporter=tap tests/*.test.ts"`.
**nuxt.config.ts** (lines 1–13): paths from `fileURLToPath(new URL('.', import.meta.url))`. A relative path resolves against the consuming app.
**Tests:** `tests/menuLinks.test.ts` lines 1–25 — `node:test` + `node:assert/strict` over pure functions, no browser. Map show JSON → boxes, locked `read`, POST body, 409 re-fetch. Page at `app/pages/connect.vue` (Nuxt merges layer pages; an app's own page wins). Render labels as text, not `v-html`. Do not proxy the OAuth API. 401 redirects to a documented login prop, default `/login`. Do not tag or push the repo in the plan. Do not edit any app's `/connect`.
README install line shape (`vue-menu-layer/README.md` lines 5–7 and 27–35):
```bash
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.2.0
```
```ts
export default defineNuxtConfig({
extends: ['vue-menu-layer'],
})
```
## Shared Patterns
### Publish / lookup
**Source:** `modules/backpack/app.go` lines 59–73
**Apply to:** user-plugin catalog, oauthserver verifier, pier PRM lookup
```go
func (a *App) Publish[T any](value T) error
func (a *App) Lookup[T any]() (T, bool)
```
### Optional second interface
**Source:** `modules/pier/backend_guard.go` lines 29–31
**Apply to:** `pact.MCPAdvertisedScopes` on the existing verifier value. Do not add methods to `MCPTokenVerifier`.
### Config list (YAML list, string slice, or comma env string)
**Source:** `modules/pier/config.go` lines 230–246
**Apply to:** `golem15.oauthserver.mcp_scopes`. Use `Config.Has` to tell unset from empty. `Config.String` returns `""` for both.
### Consent intersection (keep)
**Source:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go` lines 168–180
**Apply to:** MCP consent decide. Augment requested with `offline_access` first; do not special-case `read`.
### Consent replace vs union
**Source:** `sm-oauthserver-plugin/classes/oidc_store.go` lines 328–366 (`SaveConsent` unions; `TouchConsent` only stamps `last_used_at`)
**Apply to:** MCP must not call `SaveConsent`. Decision path replaces `scopes`. Silent path calls `TouchConsent` only.
### Grants lock then delete
**Source:** `classes/mcp_backend_binding.go` lines 43–75 and `controllers/authorizations.go` lines 134–157
**Apply to:** per-client revoke, plus `models.MCPCode`. Event stays `MCPSubjectRevoked` + `ClientID` only (`authorizations.go` lines 124–126).
### Error JSON
**Source:** `mcp_consent_api.go` `writeDecision` lines 196–208 and `authorizations.go` lines 116–120
**Apply to:** new consent and revoke branches. 401 `unauthenticated`, 404 `request_not_found` / `not_found`, 409 `backend_link_changed`, 422 `no_grantable_scopes`, 500 `server_error`.
### Boot failure on a bad declaration
**Source:** `CompileAPITokenScopes` error strings (`golem15.user: plugin %s declared ...`) and `bootAPITokenScopes` missing phrase key
**Apply to:** bad `Implies`, reserved names `backend`/`offline_access`, unknown `mcp_scopes` entry.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `vue-oauth-layer` repo (does not exist) | component | request-response | Not in this module. Copy `vue-menu-layer`, not journal. RESEARCH Pattern 10 is the page behaviour; there is no `/connect` Vue page in the framework. |
User-plugin files exist only as a v0.1.13 submodule. They are valid structural analogs. The credential-id assignment must be copied from tag v0.1.14, which is not checked out. RESEARCH quotes that function (`classes/mcp_guard.go` lines 115–143 on the tag).
## Metadata
**Analog search scope:** `modules/pact`, `modules/pier`, `modules/wristband`, `modules/bouncer`, `modules/backpack`, `modules/compass`; `sm-oauthserver-plugin`; `sm-demo-app/plugins/golem15/user` (v0.1.13, read-only); `vue-menu-layer`.
**Files scanned:** the classification table plus `oidc_store.go`, `mcp_backend_binding.go`, `mcp_verifier.go`, `models/client.go`.
**Pattern extraction date:** 2026-10-28

View File

@@ -0,0 +1,92 @@
---
phase: "32.1"
slug: "mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
created: "2026-10-28"
---
# Phase 32.1 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
> Seeded from `32.1-RESEARCH.md` § Validation Architecture. No REQ-IDs are mapped; rows use success criteria SC-1..SC-4.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go `testing` (stdlib). Plugins use testcontainers-go v0.44.0 off `-short`. vue-oauth-layer uses `node --test`. |
| **Config file** | none — Go has no repo-level test config. Layer: `package.json` script `test`, copied from vue-menu-layer. |
| **Quick run command** | `go test -short ./modules/pact ./modules/pier ./modules/wristband ./modules/bouncer` |
| **Full suite command** | `go test -short ./...` in this repo; `go test ./...` in each plugin repo (Docker); `pnpm test` in vue-oauth-layer |
| **Estimated runtime** | ~60 seconds for the quick command; plugin non-short tests depend on Docker |
---
## Sampling Rate
- **After every task commit:** `go test -short` on the packages that commit touched (`./modules/pact`, `./modules/pier`, `./modules/wristband`, or the plugin's `-short` suite), plus `go vet` on those packages.
- **After every plan wave:** `go test -short ./...` in the repo that merged. Plugin waves that touch consent or revoke also run the non-short tests named below when Docker is up.
- **Before `$msd-verify-work`:** Full `-short` suite in the framework repo, non-short consent/revoke/backfill tests in sm-oauthserver-plugin, `go test -short ./...` in sm-user-plugin on the v0.1.14 branch, `pnpm test` in the layer, `go test ./cmd/summer -run TestDocsTree`.
- **Max feedback latency:** 120 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 32.1-01-01 | 01 | 1 | SC-1 | — | Declared scope names are advertised and unknown names are rejected; OIDC discovery scopes stay unchanged | unit | `go test -short ./modules/wristband -run 'TestMCPScopes\|TestMetadata' -count=1` | ❌ W0 | ⬜ pending |
| 32.1-02-01 | 02 | 2 | SC-1 | — | `Implies` applies only to OAuth MCP subjects; personal tokens are not expanded | unit | `go test -short . -run 'TestMCP' -count=1` in sm-user-plugin on v0.1.14 | ❌ W0 | ⬜ pending |
| 32.1-01-02 | 01 | 1 | SC-1 | — | pier PRM `scopes_supported` matches the published catalog | unit | `go test -short ./modules/pier -run 'TestPRM\|TestProtected' -count=1` | ❌ W0 | ⬜ pending |
| 32.1-01-03 | 01 | 1 | SC-2 | — | `MCPCallFrom` returns the call subject on a dispatched tool and is empty on a plain request | unit | `go test -short ./modules/pier -run TestDispatch -count=1` | ❌ W0 | ⬜ pending |
| 32.1-03-01 | 03 | 3 | SC-3 | — | Consent replace, revoke of one client, and MCP code deletion do not touch another client | integration | `cd ../sm-oauthserver-plugin && go test -count=1 -run 'TestMCPConsent\|TestAuthorizations\|TestMCPRevocation'` | ❌ W0 | ⬜ pending |
| 32.1-04-01 | 04 | 4 | SC-3 | — | Consent page posts scopes as JSON and does not render phrasebook strings as HTML | unit | `pnpm test` in vue-oauth-layer | ❌ W0 | ⬜ pending |
| 32.1-05-01 | 05 | 5 | SC-4 | — | Docs identifiers resolve | unit | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
Task IDs above are placeholders until plans exist. The executor updates this map to the real task IDs.
---
## Wave 0 Requirements
- [ ] `modules/pact` test that `FrontendTokenScope` still composites without `Implies` and that `MCPCallFrom` is false on a bare context — SC-2 negative
- [ ] `modules/pier` PRM test with and without `MCPAdvertisedScopes` — SC-1
- [ ] `modules/pier` dispatch test that the context carries `MCPCall` for a frontend tool and a backend tool — SC-2
- [ ] sm-user-plugin `mcp_guard_test.go` cases for `Implies`, written against v0.1.14 — SC-1
- [ ] sm-oauthserver-plugin consent upsert, replace-not-merge, offline_access flag, revoke-deletes-MCP-code, backfill idempotence — SC-3
- [ ] `authorizations_smoke_test.go` key list includes `kind` — SC-3
- [ ] vue-oauth-layer `tests/connect.test.ts` — page mapping
- [ ] Framework install: none. Layer: `pnpm install` in the new repo once `package.json` exists
Existing wristband and pier test infrastructure hosts the new cases. No new test dependency.
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Patch tags of the framework and `sm-oauthserver-plugin`, and the changelog post | SC-4 | Tagging and publishing need an explicit user go-ahead and are not plan steps | After the code lands, ask before `git tag` or `scripts/changelog-post.py publish` |
All other phase behaviors have automated verification.
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 120s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View File

@@ -0,0 +1,334 @@
---
phase: 33-seo-output-audit-and-content
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/content.go
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/signpost/README.md
- modules/signpost/example_content_test.go
- modules/signpost/content_fields_smoke_test.go
- modules/cabana/field_type.go
- modules/cabana/http.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/cabana/field_request_options_smoke_test.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- docs/plugins/menu-item-types.md
- docs/backend/field-types.md
- scripts/check-phase33.sh
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
autonomous: false
requirements: [SC-2, SC-3, D-93, D-90, D-106, D-43, D-86, D-31, D-85, D-50, D-51, D-96, D-10]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 4
confidence: low
must_haves:
truths:
- "pact.ContentDescriptor gains Kind and Tools, pact.ContentRecord gains PublishedAt, Parents, Authors and TopHeading, and pact.ContentLocale gains Images, Videos and Headings, with the value types ContentRef, ContentPerson, ContentImage, ContentVideo and ContentHeading; every field is optional and names nothing SEO-specific (D-93, D-90)"
- "A content type that sets none of the new fields describes, lists and matches exactly as in v0.5.1; signpost.Describe and signpost.Records hand the new fields through unchanged, proven by TestContentDescriptorFields and the runnable signpost example (D-93: sitemaps and the page registry consume Records)"
- "The descriptor fields carry what JSON-LD, breadcrumbs, the image and News sitemaps, the missing-alt report and the heading checks need: first publication date, ordered candidate parents (first is the default primary), authors with an optional reference to their own page, body images with alt and a decorative flag, videos, the body's heading outline and the top heading level the body may use (D-43, D-86, D-31, D-85, D-50, D-51)"
- "ContentDescriptor.Tools names the MCP tools that change a type's records by operation (edit, headings, image_alt), so an audit tool can point every content issue at a fixing tool without importing the content plugin (SC-3, D-10, D-96)"
- "A plugin field type can supply per-request options through pact.FormFieldType.RequestOptions; cabana merges them into the field's typeOptions in the served form schema on create, update and preview, an option set in fields.yaml wins over a request option of the same key, and an element therefore reads a site setting without a token, cookie or admin route (UI-SPEC Q-2, 2026-10-28 Journal H1 setting)"
- "A RequestOptions error never fails the form: the schema is served with the fields.yaml options only and the error is logged once per request"
- "The exported API surface of the framework after this plan is strictly additive against v0.5.1: no element removed or changed and no method added to an existing interface (D-106), checked by scripts/check-phase33.sh --additive"
- "scripts/check-phase33.sh exists with the stages self-test, go, plugins, layers, consumers, docker, admin, docs, marker, additive and compat; --self-test passes and a stage whose repository is missing fails with the repository named (Wave 0)"
- "33-release-log.md lists what v0.5.2 will carry (the six Phase 32 fix commits CR-01, CR-02, CR-04, WR-01, WR-02, WR-07 plus this plan's seams) and the unreleased commits of sm-seo-plugin and sm-journal-plugin; nothing is pushed or tagged in this plan (2026-10-28 release cadence: all tags in 33-14)"
- "Jakub's answer on the meaning of pact.RecordSaved.Locale (32-UAT test 4, WR-02) is recorded before v0.5.2 freezes it"
- "The plan list of Phase 33 is 14 plans, as Jakub confirmed on 2026-10-28; this plan is the first of them and the framework part of v0.5.2 (2026-10-28 plan list)"
- {statement: "A descriptor that leaves the new slices nil and one that sets them to empty slices are treated alike by signpost and by the docs examples (no nil-versus-empty drift for consumers)", verification: backstop}
prohibitions:
- requirement_id: "D-90"
category: values
status: resolved
verification: test
statement: "No framework file added or changed by this plan names SEO, a sitemap, JSON-LD or a consuming application; the marker stage of check-phase33.sh proves it"
- requirement_id: "D-106"
category: safety
status: resolved
verification: test
statement: "No existing exported element of pact, signpost or cabana changes its signature or meaning, and no interface gains a method; v0.5.1 consumers build and test unchanged"
artifacts:
- path: "modules/pact/content.go"
provides: "additive descriptor fields and value types"
contains: "PublishedAt *time.Time"
- path: "modules/pact/capabilities.go"
provides: "FormFieldType.RequestOptions"
contains: "RequestOptions"
- path: "modules/signpost/content_fields_smoke_test.go"
provides: "TestContentDescriptorFields (tracer)"
contains: "func TestContentDescriptorFields("
- path: "modules/cabana/field_request_options_smoke_test.go"
provides: "TestFieldTypeRequestOptions"
contains: "func TestFieldTypeRequestOptions("
- path: "scripts/check-phase33.sh"
provides: "phase gate engine (Wave 0)"
contains: "--additive"
- path: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
provides: "v0.5.2 contents and the release order 33-14 follows"
contains: "v0.5.2"
key_links:
- from: "modules/pact/content.go"
to: "modules/signpost/content.go"
via: "Registry.Describe and Registry.Records return the type's *pact.ContentRecord values as they come, new fields included"
pattern: "func \\(r \\*Registry\\) (Describe|Records)\\("
- from: "modules/pact/capabilities.go"
to: "modules/cabana/http.go"
via: "the form schema handler calls each plugin field type's RequestOptions once per request and merges the map into TypeOptions"
pattern: "RequestOptions"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan gives every later plan what it needs from the framework: the content facts that JSON-LD, breadcrumbs, image and News sitemaps and the audit read from a content type (one generic, additive seam), a way for a plugin field type to receive per-request options (the Blog Editor's heading levels follow the Journal H1 setting), the phase gate script, and the release log that 33-14 follows.
<objective>
Extend the framework additively (D-90, D-106) and prepare v0.5.2 without tagging it:
- `pact.ContentDescriptor`, `pact.ContentRecord`, `pact.ContentLocale` gain optional fields (Pattern 1 of 33-RESEARCH.md, names confirmed at the Task 1 checkpoint);
- `pact.FormFieldType.RequestOptions` lets a plugin field type add per-request options to its schema entry (UI-SPEC Q-2, planner decision: a generic seam rather than an element-side fetch);
- `scripts/check-phase33.sh` with every stage the phase gate needs (Wave 0 of 33-VALIDATION.md);
- `33-release-log.md`: v0.5.2 = the unreleased Phase 32 fixes plus these seams; plugin versions are read from `git tag` in 33-14.
Purpose: Phase 33 builds on tagged versions (Q-01); every plugin in this phase develops against `../summercms.go` through `replace`, and 33-14 tags v0.5.2 with everything this plan adds.
Output: commits in this repository (code and docs separately from the planning-docs commit of the release log). Nothing is pushed or tagged.
**Planner decisions recorded here:**
- **P33-01 (descriptor shape, `[ASSUMED]` until Task 1):** `ContentDescriptor.Kind string` with constants `ContentKindArticle = "article"`, `ContentKindCollection = "collection"`, `ContentKindProfile = "profile"`, `ContentKindPage = "page"`; `ContentDescriptor.Tools map[string]string` with constants `ContentToolEdit = "edit"`, `ContentToolHeadings = "headings"`, `ContentToolImageAlt = "image_alt"` (the value is a full MCP tool name such as `journal_update_post`); `ContentRecord.PublishedAt *time.Time`, `ContentRecord.Parents []ContentRef` (ordered candidates, first is the default primary), `ContentRecord.Authors []ContentPerson`, `ContentRecord.TopHeading int` (the highest heading level the body should contain: 2 when the page renders the title as the only H1, 0 when the type sets no rule); `ContentLocale.Images []ContentImage`, `ContentLocale.Videos []ContentVideo`, `ContentLocale.Headings []ContentHeading`; `ContentRef{Type, ID string}` (Type is a menu item type key), `ContentPerson{Name string; Ref ContentRef}` (Ref.Type "" when the person has no page), `ContentImage{URL, Alt string; Decorative bool; Width, Height int}`, `ContentVideo{Provider, ID string}`, `ContentHeading{Level int; Text string}`. Each field gets a one-sentence doc comment in the existing style.
- **P33-02 (per-request field options):** `FormFieldType.RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error)` with `json:"-"`. cabana calls it at most once per field type per schema request, merges the map under the field's `typeOptions` (keys present in fields.yaml win), and logs and ignores an error. The Blog Editor (33-02) uses it for `headingLevels` and `genericAnchors`.
- **P33-03 (release log):** `.planning/phases/33-seo-output-audit-and-content/33-release-log.md` is the phase's release ledger, written like `32-06-release-log.md`: planned contents per repository now, and each executed release step appended in 33-14 so a resumed run never re-tags.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
@.planning/phases/32-seo-foundation/32-UAT.md
@CLAUDE.md
<interfaces>
modules/pact/content.go today (v0.5.1): ContentDescriptor{Controller, Model string; Records, Describe, Match funcs}; ContentList{Pattern, After, Limit, UnprefixedLocale}; ContentPage{Records []ContentRecord; Next}; ContentDescribe{ID, Pattern, UnprefixedLocale}; ContentRecord{ID string; Published bool; LastMod *time.Time; Locales []ContentLocale}; ContentLocale{Locale, URL, Title, Description, Image string; Words *int}; ContentMatch / ContentMatchResult{ID, Locale string; Exact bool}.
modules/pact/capabilities.go FormFieldType{Name, Element string; Translatable bool; Scripts, Styles, Options []string; CheckOptions, Decode, Load, ListValue, Upload funcs}; FormFieldValue{DB, Record, Field, Options, Locale, Default, Value, Translated}.
modules/cabana/schema_types.go: the served field carries TypeOptions map[string]any `json:"typeOptions,omitempty"`; modules/cabana/field_type.go:263 sets it at compile time.
admin/src/components/form/fields/PluginField.vue:44 serializes props.field.typeOptions into the element's options attribute (no SPA change needed).
scripts/check-phase32.sh: stage engine (detect_go, go_named, run_* stages, additive via scripts/apisurface against the newest tag not containing HEAD, compat via snapshots/worktrees in a scratch sibling farm, --self-test planting each case).
Framework commits after v0.5.1 that v0.5.2 carries: 676ca53 (CR-01), 5471b66 (CR-02), d660cd0 (CR-04), 468af06 (WR-01), 1583651 (WR-02), 0e187cf (WR-07).
</interfaces>
</context>
## Execution units and resume points
- Each task ends with the commit groups below, staged by explicit path (never `-A` or `.`); another session may have uncommitted `.planning/STATE.md`, `.planning/state.json`, `.planning/ROADMAP.md` or `.planning/G15OFFICE.md` changes, which stay untouched. Code and docs commits are separate from planning-docs commits.
- Resume check: `git log --format=%s | grep -Fx '<subject>'` for each subject. All present: re-run the task's `<verify>` and continue; some present: continue at the first missing group.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
| 2 | `feat(signpost): content descriptor fields for dates, parents, authors, media and outline` | TestContentDescriptorFields passes |
| 3 | `feat(cabana): per-request options for plugin field types` | TestFieldTypeRequestOptions passes |
| 4 | A `test: phase 33 gate script`; B `docs(33-01): release log for v0.5.2` | gate self-test and stages pass |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the Go shape of the descriptor fields and RequestOptions, and the meaning of RecordSaved.Locale</name>
<reversibility rating="one-way">The fields become published framework API in v0.5.2; every content plugin fills them and sm-seo-plugin, sm-sitemap-plugin and the layers read them, so renaming later breaks consumers.</reversibility>
<decision>Adopt P33-01 and P33-02 as written, adjust names, or drop RequestOptions; and confirm WR-02 (32-UAT test 4) before v0.5.2 freezes it.</decision>
<context>
P33-01 lists the proposed fields (Pattern 1 of 33-RESEARCH.md, plus Tools for SC-3 and TopHeading/Headings for the Journal H1 setting's CMS-side check). They name no SEO concept: feeds, search indexers and audit tools use the same facts. P33-02 is the planner's resolution of UI-SPEC Q-2: the Blog Editor needs the per-site heading levels and generic-anchor list on every request, and field options are compiled at boot today. The alternative is that the editor fetches a public Journal endpoint, which breaks the element rule "no direct route" (UI-SPEC Shared rules) less cleanly. WR-02 (commit 1583651) set RecordSaved.Locale to the primary tag of the request locale; 32-UAT test 4 asks Jakub to accept that or ask for the content-locale meaning.
</context>
<options>
<option id="as-planned">
<name>Adopt P33-01 and P33-02 as written; accept WR-02's request-locale meaning</name>
<pros>Unblocks 33-02 and 33-03 with the shape research and UI-SPEC assume; one framework release carries every Phase 33 seam</pros>
<cons>Two seams freeze at once in v0.5.2</cons>
</option>
<option id="adjust">
<name>Adopt with the names or fields Jakub changes (stated in the answer)</name>
<pros>Names fixed before they become API</pros>
<cons>Later plans use the adjusted names; the executor records them in the SUMMARY for every downstream plan</cons>
</option>
<option id="no-request-options">
<name>Adopt P33-01 only; the editor reads its options from a public Journal endpoint</name>
<pros>One framework seam fewer</pros>
<cons>The Blog Editor element makes its own request, against the UI-SPEC element rule; 33-02 Task 4 changes accordingly</cons>
</option>
</options>
<resume-signal>Reply "as-planned", "adjust: <changes>" or "no-request-options", plus "WR-02: accept" or "WR-02: content locale"</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "a content type describes a record with dates, parents, authors, images, videos and its outline, and signpost hands them through" — one path only</name>
<files>modules/pact/content.go, modules/signpost/content_fields_smoke_test.go, modules/signpost/example_content_test.go, modules/pact/README.md, modules/signpost/README.md, docs/plugins/menu-item-types.md</files>
<read_first>
- modules/pact/content.go (whole file; the doc-comment style to copy)
- modules/signpost/content.go (Describe, Records, ContentTypes, ContentTypeForModel)
- modules/signpost/content_test.go and modules/signpost/example_content_test.go (the fake content type the tests use)
- docs/plugins/menu-item-types.md (the content descriptor section)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 1)
</read_first>
<action>
Per D-93 and P33-01 (as answered in Task 1): add the fields and the five value types to `modules/pact/content.go`, each with a one-sentence comment in the file's style and no SEO wording (D-90): Kind and Tools on ContentDescriptor with the Kind and Tool constants; PublishedAt, Parents, Authors, TopHeading on ContentRecord; Images, Videos, Headings on ContentLocale. Tools values are full MCP tool names. No existing field, signature or comment meaning changes (D-106). signpost needs no logic change if Describe and Records already return the type's records as given; read `modules/signpost/content.go` and change it only if it copies records field by field. Add `modules/signpost/content_fields_smoke_test.go` with `TestContentDescriptorFields`: register a fake type with Kind article and Tools {edit: acme_update_page}, whose Describe answers PublishedAt, two Parents, one Author with a Ref, two Images (one decorative with empty alt), one Video and three Headings with TopHeading 2; assert signpost.Describe and signpost.Records return every value unchanged and that `ContentType(key)` exposes Kind and Tools; a second fake type that sets none of them describes exactly as before. Extend the runnable example in `example_content_test.go` with the new fields (Output comment updated). Document the fields in the descriptor tables of `modules/pact/README.md`, `modules/signpost/README.md` and `docs/plugins/menu-item-types.md` (every identifier must exist; the docs checker enforces it). Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/signpost -run 'TestContentDescriptorFields|Example' -count=1 -v 2>&1 | tail -30</automated>
<fails_when>non-zero exit, a "--- FAIL" line, "no tests to run", or no "--- PASS: TestContentDescriptorFields" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>go test ./cmd/summer -run TestDocsTree -count=1 2>&1 | tail -3 && go run ./cmd/summer docs:build --check 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or a "FAIL" or "unknown identifier" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "PublishedAt \*time.Time" modules/pact/content.go` prints one line; `grep -n "^type Content\(Ref\|Person\|Image\|Video\|Heading\) struct" modules/pact/content.go` prints five lines
- `grep -n "ContentKindArticle\|ContentToolImageAlt" modules/pact/content.go` prints at least two lines
- `go doc ./modules/pact ContentRecord` lists PublishedAt, Parents, Authors and TopHeading
- TestContentDescriptorFields covers the set-everything type and the set-nothing type and passes
- docs/plugins/menu-item-types.md names every new field
</acceptance_criteria>
<done>A content type can describe the new facts and every consumer of signpost receives them; types that do not set them are unaffected.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Per-request options for plugin field types (RequestOptions) merged into the served schema</name>
<files>modules/pact/capabilities.go, modules/cabana/field_type.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/field_request_options_smoke_test.go, modules/pact/README.md, docs/backend/field-types.md, admin/openapi/admin.json, admin/src/api/schema.d.ts</files>
<read_first>
- modules/pact/capabilities.go (FormFieldType ~689-725)
- modules/cabana/field_type.go (compile of TypeOptions ~240-270, Decode/Load call sites)
- modules/cabana/http.go (the per-request form schema filter, where visible fields are chosen per administrator)
- modules/cabana/schema_types.go (TypeOptions JSON)
- modules/cabana/admin_openapi.go (~566, the form description that names typeOptions)
- an existing plugin field type test in modules/cabana (grep -ln "FormFieldType{" modules/cabana/*_test.go) for the fixture pattern
- docs/backend/field-types.md
</read_first>
<behavior>
- A field type with RequestOptions returning {headingLevels: [2,3,4]} serves typeOptions containing headingLevels on the create form schema, the update form schema and the preview schema
- The same key set in fields.yaml for that field wins over the request value
- RequestOptions runs once per schema request for each field type, however many fields use the type
- RequestOptions returning an error serves the schema with the fields.yaml options only, answers 200, and logs one warning naming the field type
- A field type without RequestOptions serves byte-identical schema JSON to v0.5.1 (CanonicalJSON comparison like TestFormExtensionUnextendedSchemaUnchanged)
- A form extension's plugin field (32-01) gets its request options the same way
</behavior>
<action>
Per P33-02: add `RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error)` with `json:"-"` and a doc comment to `pact.FormFieldType`. In cabana's form schema path (http.go, where the per-request schema copy is built), for each plugin field whose type has RequestOptions, call it once per type per request with the request's read DB, and merge the result into a copy of that field's TypeOptions (never mutate the compiled schema; keys already present win). On error, log once with the type name and serve the unmerged options. Write `TestFieldTypeRequestOptions` (subtests per behavior line) in `modules/cabana/field_request_options_smoke_test.go` first, then implement. Update the swag description in `admin_openapi.go` (typeOptions: "the type's own fields.yaml keys, plus per-request options the type supplies") and regenerate with `scripts/check-admin-openapi.sh`; commit `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` only if they change. Document RequestOptions in `docs/backend/field-types.md`, `modules/cabana/README.md` and `modules/pact/README.md`. Commit as listed in Execution units.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestFieldTypeRequestOptions|TestFormExtensionUnextendedSchemaUnchanged' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestFieldTypeRequestOptions" line</fails_when>
<automated>scripts/check-admin-openapi.sh --check</automated>
<fails_when>non-zero exit (the committed OpenAPI or schema.d.ts differs from the generated one)</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "RequestOptions func(ctx context.Context, db \*gorm.DB) (map\[string\]any, error)" modules/pact/capabilities.go` prints one line
- `grep -n "RequestOptions" modules/cabana/http.go modules/cabana/field_type.go` prints at least one line
- `grep -n "RequestOptions" docs/backend/field-types.md` prints at least one line
- TestFieldTypeRequestOptions has a subtest for each behavior line and passes
</acceptance_criteria>
<done>A plugin field type can deliver per-request options to its element through the existing typeOptions attribute; nothing changes for other field types.</done>
</task>
<task type="auto">
<name>Task 4: Phase gate script and the v0.5.2 release log</name>
<files>scripts/check-phase33.sh, .planning/phases/33-seo-output-audit-and-content/33-release-log.md</files>
<read_first>
- scripts/check-phase32.sh (whole file: usage, detect_go, go_named, run_* stages, run_self_test, additive_compare, run_compat, run_all, run_stage)
- scripts/apisurface/main.go
- .planning/phases/32-seo-foundation/32-06-release-log.md (ledger format)
- .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md (Wave 0 and the decision-to-test map)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Validation Architecture, release order)
</read_first>
<action>
Create `scripts/check-phase33.sh` from check-phase32.sh's engine (keep detect_go, go_named, the scratch sibling farm, additive and compat logic and the self-test planting style; label everything Phase 33). Stages and what each runs:
- `--go`: `go vet ./...` and `go test -short ./...` here;
- `--plugins`: vet and `test -short` in ../sm-sitemap-plugin, ../sm-seo-plugin, ../sm-journal-plugin, plus `npm --prefix <repo> run test` (vitest) in sm-seo-plugin and sm-journal-plugin;
- `--layers`: for ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer: `npm --prefix <layer> run test`, `npm --prefix <layer> run build:playground`, and the layer's SSR smoke (journal: `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt`; seo: `npm --prefix ../vue-seo-layer run smoke:ssr`; menu and faq: `npm --prefix <layer> run smoke:ssr`), and `node testing/guard-check.mjs` in vue-seo-layer (the build-guard fixtures 33-08 adds);
- `--consumers`: build sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app (Go `go build ./...`; the Nuxt apps' `build` script);
- `--docker`: the Docker suites (`go test ./...` without -short) of the three Go plugins;
- `--admin`: `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh`;
- `--docs`: TestDocsTree and `go run ./cmd/summer docs:build --check`;
- `--marker`: the framework diff since v0.5.1 (`git diff v0.5.1 -- modules docs`) names no SEO word, sitemap, JSON-LD or consuming application (the D-90 word list of check-phase32.sh's marker stage, plus the app names grzyby, fonoteka, plytarium, summercmsio, demo.summercms);
- `--additive`: apisurface of every changed framework package against the newest tag not containing HEAD;
- `--compat`: three consumers tested (sm-sitemap-plugin, sm-journal-plugin, sm-demo-app) and every other sibling that requires the framework built.
A stage whose repository or directory is missing fails with "missing <path>" (33-14's `--all` needs every repository; vue-seo-layer appears in 33-07). `--self-test` plants: an additive removal, an interface method added, a marker hit, a failing go test, a missing repo; each must be detected. Then write `33-release-log.md` with sections "Planned contents" (framework v0.5.2 = the six fix SHAs above plus this plan's two seams; `git -C ../sm-seo-plugin log --oneline v0.1.0..HEAD` and `git -C ../sm-journal-plugin log --oneline v0.2.5..HEAD` pasted as the unreleased plugin fixes), "Release order" (framework → sm-journal-plugin → sm-sitemap-plugin → sm-seo-plugin → vue-seo-layer → vue-journal-layer, vue-menu-layer, vue-faq-layer → apps; versions read from `git tag` in 33-14), and an empty "Executed steps" table. Commit group A (script) and group B (release log, planning docs).
</action>
<verify>
<automated>bash scripts/check-phase33.sh --self-test</automated>
<fails_when>non-zero exit, or a planted case reported as undetected</fails_when>
<automated>bash scripts/check-phase33.sh --go --docs --marker --additive</automated>
<fails_when>non-zero exit, or a "FAIL", "refuse:" or marker hit line</fails_when>
</verify>
<acceptance_criteria>
- `bash scripts/check-phase33.sh` with no argument prints a usage listing all eleven stage flags and exits 2
- `grep -c "missing " scripts/check-phase33.sh` is at least 1 (missing-repository failure path)
- `grep -n "676ca53\|5471b66\|d660cd0\|468af06\|1583651\|0e187cf" .planning/phases/33-seo-output-audit-and-content/33-release-log.md` prints six lines
- the additive stage compares against v0.5.1 and passes
</acceptance_criteria>
<done>The phase has its gate script and a release ledger; v0.5.2's contents are written down and nothing is released.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| plugin code → framework API | plugins fill descriptor fields and RequestOptions; consumers trust the values only as data |
| admin SPA ← form schema | request options reach the browser inside typeOptions |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-01 | Information disclosure | RequestOptions in the served schema | medium | mitigate | the schema is served only to authenticated administrators with access to the form (existing cabana guard); the doc comment states that request options must hold no secret, and TestFieldTypeRequestOptions asserts the merge happens only in the authenticated schema handler |
| T-33-02 | Denial of service | RequestOptions called per field | low | mitigate | called once per field type per request (behavior test), errors fail open |
| T-33-03 | Tampering | breaking published API | high | mitigate | additive stage of check-phase33.sh (apisurface vs v0.5.1) and the Task 1 checkpoint |
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | this plan installs no package |
</threat_model>
<verification>
- `go vet ./... && go test -short ./...` green after every commit.
- `bash scripts/check-phase33.sh --self-test` and `--go --docs --marker --additive` pass.
- `scripts/check-admin-openapi.sh --check` passes.
</verification>
<success_criteria>
- The descriptor fields and RequestOptions exist with docs and smoke tests, strictly additive against v0.5.1.
- check-phase33.sh exists and its self-test passes.
- The release ledger records v0.5.2's contents; no push, no tag.
</success_criteria>
## Artifacts this phase produces
- Struct fields: `pact.ContentDescriptor.Kind`, `pact.ContentDescriptor.Tools`, `pact.ContentRecord.PublishedAt`, `pact.ContentRecord.Parents`, `pact.ContentRecord.Authors`, `pact.ContentRecord.TopHeading`, `pact.ContentLocale.Images`, `pact.ContentLocale.Videos`, `pact.ContentLocale.Headings`, `pact.FormFieldType.RequestOptions`.
- Types: `pact.ContentRef`, `pact.ContentPerson`, `pact.ContentImage`, `pact.ContentVideo`, `pact.ContentHeading`.
- Constants: `pact.ContentKindArticle`, `pact.ContentKindCollection`, `pact.ContentKindProfile`, `pact.ContentKindPage`, `pact.ContentToolEdit`, `pact.ContentToolHeadings`, `pact.ContentToolImageAlt`.
- Tests: `TestContentDescriptorFields` (modules/signpost/content_fields_smoke_test.go), `TestFieldTypeRequestOptions` (modules/cabana/field_request_options_smoke_test.go).
- CLI: `scripts/check-phase33.sh` with `--self-test`, `--go`, `--plugins`, `--layers`, `--consumers`, `--docker`, `--admin`, `--docs`, `--marker`, `--additive`, `--compat`, `--all`.
- Files: `.planning/phases/33-seo-output-audit-and-content/33-release-log.md`.
- Config keys: none.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md` when done; record the Task 1 answer (including any renamed field) at the top so plans 33-02 to 33-14 use the final names.
</output>

View File

@@ -0,0 +1,372 @@
---
phase: 33-seo-output-audit-and-content
plan: 02
type: execute
wave: 2
depends_on: ["33-01"]
files_modified:
- ../sm-journal-plugin/models/settings.go
- ../sm-journal-plugin/models/settings/fields.yaml
- ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go
- ../sm-journal-plugin/config/config.yaml
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/classes/headings.go
- ../sm-journal-plugin/classes/journal_settings.go
- ../sm-journal-plugin/classes/blog_editor.go
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/classes/media_store.go
- ../sm-journal-plugin/classes/media_names.go
- ../sm-journal-plugin/classes/image_alt.go
- ../sm-journal-plugin/classes/content_descriptors.go
- ../sm-journal-plugin/classes/content_facts.go
- ../sm-journal-plugin/classes/menu_item_types.go
- ../sm-journal-plugin/menu_item_types.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_helpers.go
- ../sm-journal-plugin/controllers/api/categories_test.go
- ../sm-journal-plugin/controllers/api/settings.go
- ../sm-journal-plugin/controllers/api/maintenance.go
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/console/fix_headings.go
- ../sm-journal-plugin/console/import_posts.go
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
- ../sm-journal-plugin/assets/src/blogeditor/outline.ts
- ../sm-journal-plugin/assets/src/blogeditor/linkform.ts
- ../sm-journal-plugin/assets/src/blogeditor/imageform.ts
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
- ../sm-journal-plugin/assets/dist/blogeditor.js
- ../sm-journal-plugin/assets/dist/blogeditor.css
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
- ../sm-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/headings_test.go
- ../sm-journal-plugin/journal_descriptor_fields_test.go
- ../sm-journal-plugin/classes/media_names_test.go
autonomous: true
requirements: [SC-2, SC-3, D-50, D-51, D-81, D-83, D-85, D-96, D-43, D-93, D-10]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 4
confidence: low
must_haves:
truths:
- "Journal has a per-site setting content_h1_to_h2 (Settings › Journal, new tab Headings and links, switch default on) with the config key golem15.journal.content_h1_to_h2 as the value when no settings row exists (2026-10-28 Journal H1 setting, amends D-50)"
- "While the setting is on, every H1 saved in Journal rich text becomes an H2 on every Journal rich-text write path, post content and category pillar text alike: admin saves of posts and categories (the mlblogeditor and blogeditor field types), the /_journal/api/v1 write API (and so MCP), the markdown path and the import command (categories have no API or import write path today, so their pillar text is written through the admin form only); a post save answers the plural notice journal:editor.h1_converted and a category save journal:editor.h1_converted_category on the editor field, and the API answer carries the post phrase in its notices (D-50, D-96, UI-SPEC C27, CONTEXT 2026-10-28 H1 conversion scope)"
- "While the setting is off, H1 in post content and category pillar text is stored as written, the editor offers H1 to H4, no conversion notice appears, and journal:fix-headings refuses with exit code 1 and the copy that names the setting (2026-10-28 Journal H1 setting, UI-SPEC partial: H1 setting off)"
- "Switching the setting on or off never rewrites stored content; only journal:fix-headings --apply (dry run by default) and the MCP tool journal_fix_headings with dry_run false convert stored H1 headings, in post content (default and translated) and in category pillar text, and report both counts (D-50, UI-SPEC Destructive actions, CONTEXT 2026-10-28 H1 conversion scope)"
- "GET /_journal/api/v1/posts/{slug} and every category row of GET /_journal/api/v1/categories carry content_h1_to_h2 (boolean, additive key) so the journal layer reads the setting from the Journal API for post bodies and category pillar text, not from a layer option (2026-10-28 Journal H1 setting, H1 conversion scope)"
- "The Blog Editor receives headingLevels and genericAnchors per request through RequestOptions: H2 to H4 with a pasted H1 parsed as H2 while the setting is on, H1 to H4 while it is off (D-51, UI-SPEC C28 J1)"
- "The editor's outline panel (J2) lists headings in document order and flags skipped levels, empty headings, link-only headings, an H1 in Source mode while the setting is on, and links whose whole text is on the generic list; it recomputes 300 ms after a change, starts collapsed, never takes focus on load and never edits content (D-51, D-81)"
- "The link form (J3) offers nofollow, sponsored and ugc checkboxes only for absolute http(s) URLs, writes rel with the checked values, never puts rel on internal links, and warns without blocking when the link text is on the generic list; the stored-HTML policy keeps rel with only those three tokens (D-83, D-81, D-80)"
- "The image form (J4) sets a slugged file name before upload (lowercase, Polish diacritics transliterated, non-alphanumeric runs to one hyphen, at most 80 characters, empty becomes image), requires alt unless Decorative image is checked, and inserts alt=\"\" for a decorative image; the server applies the same slug rule to uploaded file names (D-85)"
- "Journal's descriptors fill the 33-01 fields: posts give PublishedAt, Parents (their categories in order), Authors (with a journal-author reference when author pages are on), Images (featured image plus body images with alt and decorative), Videos (YouTube embeds), Headings and TopHeading (2 while the setting is on, 0 while off), Kind article and Tools {edit: journal_update_post, headings: journal_fix_headings, image_alt: journal_set_image_alt}; categories give Parents (their parent), Kind collection, Headings from their pillar text, TopHeading (2 while the setting is on, 0 while off) and Tools {headings: journal_fix_headings}; tags give Kind collection (D-93, D-43, D-96, CONTEXT 2026-10-28 H1 conversion scope)"
- "A new journal-author menu item type describes author pages (Kind profile, Records of authors with at least one published post, exact Match on the author pattern, host key golem15.journal.author_pages default true), so author pages get the SEO tab, ProfilePage data and a sitemap group (D-43, D-34)"
- "MCP has journal_get_settings, journal_update_settings, journal_fix_headings (dry_run) and journal_set_image_alt (dry_run, bulk), each bound to a /_journal/api/v1 route with golem15.journal.manage_settings or golem15.journal.access_posts (D-10, D-85, UI-SPEC MCP parity)"
- "The admin save notice, the outline counts and every plural phrase use CLDR maps (en one/other, pl one/few/many/other) (UI-SPEC zero-one-many)"
- "J2 shows outlineEmpty when the text has no heading (UI-SPEC empty: J2)"
- "J4 keeps the existing uploading status line until the upload result arrives, then inserts the image (UI-SPEC loading: J4)"
- {statement: "J4 slug rule: \"Lwia Grzywa – w lesie!.JPG\" becomes lwia-grzywa-w-lesie with the extension .jpg, an empty name becomes image, and names are cut at 80 characters (UI-SPEC J4 backstop)", verification: backstop}
- "Planner assumption (UI-SPEC unresolved, J2 long text): outline items with long heading text wrap inside the panel with a hanging indent under the level tag and are never truncated; verify in the editor at 360 px"
- "The setting's label, comment and console output name category content as well as post content, with the P33-54 wording that adapts UI-SPEC C27 to the wider scope (CONTEXT 2026-10-28 H1 conversion scope)"
prohibitions:
- requirement_id: "D-50"
category: values
status: resolved
verification: test
statement: "Turning the H1 setting on or off never rewrites already stored content"
- requirement_id: "D-96"
category: values
status: resolved
verification: judgment
statement: "Journal never imports sm-seo-plugin or names SEO settings; everything SEO reads comes through the signpost descriptor"
artifacts:
- path: "../sm-journal-plugin/classes/headings.go"
provides: "ConvertH1 on the x/net/html tokenizer"
contains: "func ConvertH1("
- path: "../sm-journal-plugin/console/fix_headings.go"
provides: "journal:fix-headings"
contains: "journal:fix-headings"
- path: "../sm-journal-plugin/classes/content_facts.go"
provides: "body images, videos and headings from content_html"
contains: "pact.ContentHeading"
- path: "../sm-journal-plugin/headings_test.go"
provides: "TestHeadingConversionTracer, TestHeadingConversion, TestFixHeadingsCommand, TestSetImageAlt, TestJournalSettingsTools"
contains: "func TestHeadingConversionTracer("
- path: "../sm-journal-plugin/journal_descriptor_fields_test.go"
provides: "TestJournalDescriptorFields, TestJournalAuthorType"
contains: "func TestJournalDescriptorFields("
key_links:
- from: "../sm-journal-plugin/classes/blog_editor.go"
to: "../sm-journal-plugin/classes/headings.go"
via: "decodeBlogHTML calls ConvertH1 when the setting is on and returns the h1_converted notice"
pattern: "ConvertH1\\("
- from: "../sm-journal-plugin/controllers/api/posts.go"
to: "../sm-journal-plugin/classes/journal_settings.go"
via: "Show adds content_h1_to_h2 from the effective setting; Store and Update convert and return notices"
pattern: "content_h1_to_h2"
- from: "../sm-journal-plugin/classes/blog_editor.go"
to: "summercms.go/modules/pact/capabilities.go"
via: "the blogeditor and mlblogeditor types set RequestOptions to the settings' heading levels and generic list"
pattern: "RequestOptions"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan makes Journal, the first content plugin, supply what the rest of the phase consumes and fix its own content (D-96): the per-site H1 setting with conversion on every write path, the editor's heading levels, outline, link rel and image naming, the new descriptor facts, author pages as a content type, and MCP tools for its settings, stored headings and image alt.
<objective>
Implement in `../sm-journal-plugin` (branch main, not pushed):
- the Journal H1 setting (2026-10-28, amends D-50/D-51): column, config key, settings tab, API flag, conversion with notices on every Journal rich-text write path for post content and category pillar text, `journal:fix-headings` over both (CONTEXT 2026-10-28 H1 conversion scope);
- Blog Editor J1–J4 (UI-SPEC C28) on RequestOptions (33-01 P33-02), with the stored-HTML policy keeping `rel` (D-83) and server-side slugged media names (D-85);
- descriptor facts for posts, categories and tags plus a `journal-author` type (D-93, D-43);
- MCP tools `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` (D-10, D-85).
Purpose: the layer's title element and htmlGate demotion (33-09), JSON-LD and breadcrumbs (33-06), image and News sitemaps (33-04) and the audit's CMS checks (33-10) all read what this plan provides.
Output: commits in ../sm-journal-plugin (main, not pushed; 33-14 tags). The framework is used through the existing `replace => ../summercms.go`.
**Planner decisions recorded here:**
- **P33-04 (setting precedence):** the settings row's `content_h1_to_h2` decides; the config key `golem15.journal.content_h1_to_h2` (default true, new `config/config.yaml` shipped through `ConfigFS`) is used only when no settings row exists. `classes.EffectiveH1ToH2(ctx, db, cfg) bool` is the one reader; README documents it.
- **P33-05 (bulk alt tool lives in Journal):** the UI-SPEC's `[ASSUMED]` `seo_set_image_alt` becomes `journal_set_image_alt`: alt text is post content, which only Journal may write (D-96); the SEO issue report points at it through `ContentDescriptor.Tools["image_alt"]` (33-10). Research had the tool in 33-11; it moves here with Journal's other content fixes.
- **P33-06 (featured image alt):** the featured image alt in descriptors and in the API stays as today (UI-SPEC Q-6): the attachment's title when set, otherwise the post title.
- **P33-07 (author type URL):** `journal-author` default pattern `/blog/author/:author` (the journal layer's default route), host override through `menu_items.patterns`, `:author` is the backend user's author_slug.
- **P33-54 (H1 scope wording, CONTEXT 2026-10-28 H1 conversion scope):** Jakub's decision widens the setting from post content to every Journal rich-text field, post content and category pillar text alike, so the C27 copy that names only posts is adapted; every other C27 string stays verbatim. Label `journal:journal.h1_to_h2_label`: EN "Convert H1 to H2 in post and category content", PL "Zamieniaj H1 na H2 w treści wpisów i kategorii". Comment `journal:journal.h1_to_h2_comment`: the first sentence becomes EN "Turn it off only when the page template does not show the post title or the category name as the H1." and PL "Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1."; the rest of C27's comment is unchanged. New notice key `journal:editor.h1_converted_category` for category saves, worded like `editor.h1_converted` with "the category name" in place of "the post title" (EN one/other ":count H1 heading was converted to H2, because the page shows the category name as the H1." / ":count H1 headings were converted to H2, because the page shows the category name as the H1."; PL one/few/many/other "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1." / "…nagłówki…" / "…nagłówków…" / "…nagłówka…"). Console (English only): dry run "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save.", applied "Converted :count H1 headings in :posts posts and :categories categories.", nothing to do "No H1 headings found in post or category content."; the refusal line stays as C27 words it.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-01 (final names in 33-01-SUMMARY.md): pact.ContentDescriptor{Kind, Tools}; pact.ContentRecord{PublishedAt, Parents []ContentRef, Authors []ContentPerson, TopHeading}; pact.ContentLocale{Images []ContentImage, Videos []ContentVideo, Headings []ContentHeading}; pact.FormFieldType.RequestOptions func(ctx, *gorm.DB) (map[string]any, error).
sm-journal-plugin today: models/settings.go (row ID 1, Fillable/Rules), models/settings/fields.yaml (tabs general, search, rss), classes/blog_editor.go (BlogEditorTypes, decodeBlogHTML returns pact.FormFieldDecoded{Value, Notices}), classes/format_html.go (bluemonday UGC policy, RequireNoFollowOnLinks(false), SanitizeHTMLReport), classes/media_store.go (StoredMedia{URL, Path}), classes/content_descriptors.go (describePosts, firstImages, describeCategories, describeTag, TagPagesKey), classes/menu_item_types.go (MenuItemTypes, menuAlternates), controllers/api/posts.go (Show ~122, Store, Update, serializePost ~587, featuredImages ~629), controllers/api/rss.go:17-40 (settings row read with defaults), console/export_posts.go:24-60 (bonfire.Command with Bare dry-run flag), mcp.go (tools bound to /_journal/api/v1 routes, permission mcpAccessPosts), admin_permissions.go (golem15.journal.manage_settings, golem15.journal.access_posts), routes.go (public group throttle journal-public-api, write group throttle journal-api).
Editor: assets/src/blogeditor/{element.ts (attributes field-name, record-id, locale, ui-locale, options JSON, disabled), toolbar.ts:45-47 heading2..4, extensions.ts:80 heading, labels.ts (format with {name}), blogeditor.css}; tests/blogeditor/*; scripts/check-dist.sh.
Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor additions (C28)" tables, en and pl, verbatim.
</interfaces>
</context>
## Execution units and resume points
- Commit groups per task, staged by path in ../sm-journal-plugin with `git -c safe.directory='*' -C ../sm-journal-plugin add <paths>` then `commit -m "<subject>"`. Leave `go.work.sum` (untracked, another session's) alone. Every commit keeps `go vet ./...` and `go test -short ./...` green there; each group adds the lang keys it uses to both lang files.
- Resume check: `git -c safe.directory='*' -C ../sm-journal-plugin log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: H1 to H2 setting with the admin save conversion (tracer)` | TestHeadingConversionTracer passes |
| 2 | A `feat: H1 conversion on the API, markdown and import paths`; B `feat: journal:fix-headings and the settings API`; C `feat: MCP tools for settings, headings and image alt` | all write paths and tools pass |
| 3 | A `feat: descriptor dates, parents, authors, media and outline`; B `feat: journal-author content type` | descriptor tests pass |
| 4 | A `feat: stored HTML keeps link rel and media names are slugged`; B `feat: editor heading levels and outline panel`; C `feat: editor link rel, generic-anchor warning and image naming`; D `docs: headings, editor and MCP tools in README` | dist check passes; plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor saves a post with an H1 and gets it back as an H2 with a notice, and the public API reports the setting" — one path only</name>
<files>../sm-journal-plugin/models/settings.go, ../sm-journal-plugin/models/settings/fields.yaml, ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/classes/headings.go, ../sm-journal-plugin/classes/journal_settings.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go</files>
<read_first>
- ../sm-journal-plugin/models/settings.go, models/settings/fields.yaml, updates/202610060006_create_golem15_journal_settings.go, updates/registry.go
- ../sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go (additive ALTER with Rollback)
- ../sm-seo-plugin/config/config.yaml and ../sm-seo-plugin/plugin.go:20-30,79-80 (ConfigFS, HasConfig)
- ../sm-journal-plugin/classes/blog_editor.go (decodeBlogHTML, removalNotices) and classes/format_html.go (SanitizeHTMLReport)
- ../sm-journal-plugin/controllers/api/posts.go (Show, serializePost) and controllers/api/rss.go:17-40
- ../sm-journal-plugin/posts_admin_smoke_test.go (admin harness for a post save)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 and its copy)
</read_first>
<action>
Per the 2026-10-28 Journal H1 setting and P33-04: migration `202610280001_add_golem15_journal_settings_headings` adds `content_h1_to_h2 boolean NOT NULL DEFAULT true` and `generic_anchors text NULL` to golem15_journal_settings (Rollback drops both), registered in `updates/registry.go` style; model fields ContentH1ToH2 bool and GenericAnchors *string added to Fillable and Rules (boolean; nullable|string|max:5000). fields.yaml gets the tab `golem15.journal::lang.journal.tab_headings` after General with `content_h1_to_h2` (switch, span left, default true, label h1_to_h2_label, comment h1_to_h2_comment) and `generic_anchors` (textarea size small, span full, default = the en and pl seed lists of C27 joined by newlines). New `config/config.yaml` with the commented key `content_h1_to_h2: true`; plugin.go embeds it, implements `ConfigFS()` and asserts `pact.HasConfig`. `classes/journal_settings.go`: `EffectiveH1ToH2(ctx, db, cfg) bool` and `GenericAnchors(ctx, db) []string` (row value, else the seed lists). `classes/headings.go`: `ConvertH1(html string) (string, int)` rewriting `<h1 ...>`/`</h1>` start and end tags to h2 with the x/net/html tokenizer, keeping attributes and every other byte. decodeBlogHTML calls it when EffectiveH1ToH2 is true and appends the notice `golem15.journal::lang.editor.h1_converted` with Count n (FormFieldNotice). Show adds `content_h1_to_h2` to the post payload (additive key; no existing key changes). Lang keys of C27 in en and pl with the P33-54 wording for the label and comment (CLDR plurals). `TestHeadingConversionTracer` in headings_test.go: admin save of a post whose content holds `<h1>A</h1><p>x</p>` stores `<h2>A</h2><p>x</p>` and the save response meta.notices has the converted phrase with count 1; GET /_journal/api/v1/posts/{slug} answers content_h1_to_h2 true; with the setting switched off the same save stores the H1 unchanged, adds no notice, and Show answers false. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversionTracer' -count=1 -v 2>&1 | tail -20</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestHeadingConversionTracer" line</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func ConvertH1(" ../sm-journal-plugin/classes/headings.go` prints one line
- `grep -n "content_h1_to_h2" ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go ../sm-journal-plugin/models/settings/fields.yaml ../sm-journal-plugin/controllers/api/posts.go` prints a line in each file
- `grep -n "h1_converted" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each
- TestHeadingConversionTracer asserts both setting states and passes
</acceptance_criteria>
<done>The setting exists, the admin save converts H1 with a notice while it is on, and the public post answer reports it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Conversion on every other write path (post content and category pillar text), journal:fix-headings over both, the settings API and the MCP tools (settings, fix headings, set image alt)</name>
<files>../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/controllers/api/categories_test.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/controllers/api/settings.go, ../sm-journal-plugin/controllers/api/maintenance.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/image_alt.go, ../sm-journal-plugin/console/fix_headings.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go</files>
<read_first>
- ../sm-journal-plugin/controllers/api/posts.go (Store, Update, the transaction and RecordSaved emission from 62c75b1/7a03640)
- ../sm-journal-plugin/controllers/api/record_events.go and auth.go (backend-audience JWT, permissions)
- ../sm-journal-plugin/console/export_posts.go:24-60 and console/import_posts.go
- ../sm-journal-plugin/mcp.go and mcp_test.go (tool binding pattern)
- ../sm-journal-plugin/journal_api_writes_test.go (API write harness)
- ../sm-journal-plugin/controllers/api/posts_helpers.go (category serializer, post_count) and controllers/api/categories_test.go (exact key lists), models/category.go (Content is the pillar text), categories_admin_editor_test.go (category admin save harness)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 console copy, MCP parity row) and P33-54 above (the wording that replaces C27's post-only strings)
</read_first>
<behavior>
- API Store and Update of content `<h1 class="x">T</h1>` store `<h2 class="x">T</h2>` and the JSON answer gains `notices: [{message, count}]` with the h1_converted phrase; translated content in a locale converts the same way; with the setting off nothing converts and `notices` is absent
- Markdown content (FormatHTML path) with `# T` stores an h2 while on
- `journal:import-posts` converts while on and prints the converted count
- An admin save of a category whose pillar text holds an H1 stores an H2 while on, with the notice journal:editor.h1_converted_category on the content field; while off it stores the H1 unchanged without a notice
- GET /_journal/api/v1/categories rows carry content_h1_to_h2 matching the setting (categories_test.go's key lists gain it; every other key unchanged)
- `journal:fix-headings` (dry run default) prints "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save." and changes nothing; `--apply` converts default and translated post content and category pillar text, fires RecordSaved per changed post and category, prints "Converted :count H1 headings in :posts posts and :categories categories."; nothing to do prints "No H1 headings found in post or category content."; with the setting off it exits 1 with the refusal copy
- GET/PUT /_journal/api/v1/settings read and write content_h1_to_h2 and generic_anchors (other settings columns too) with golem15.journal.manage_settings; 403 without it
- POST /_journal/api/v1/fix-headings {dry_run} answers {posts, categories, headings, dry_run} and refuses 409 with the refusal phrase while the setting is off
- POST /_journal/api/v1/images/alt {dry_run, changes:[{post_id, locale, src, alt}]} sets alt on the matching img in content_html (default or translated), answers the per-change diff, refuses the whole batch 422 when one src is not in the post, and with dry_run writes nothing
- MCP lists journal_get_settings, journal_update_settings, journal_fix_headings and journal_set_image_alt, each bound to its route
</behavior>
<action>
Per D-50, D-96, D-85, P33-05 and P33-54 (CONTEXT 2026-10-28 H1 conversion scope: every Journal rich-text write path, post content and category pillar text alike): call ConvertH1 in the API write paths after sanitizing (default and translated content_html, inside the existing transaction) and return the additive `notices` key; apply it in FormatHTML's caller for markdown content and in console/import_posts.go. decodeBlogHTML (already converting since Task 1 for both blogeditor types) picks the notice key by the field's record: `editor.h1_converted_category` for a Category, `editor.h1_converted` otherwise. The categories serializer in posts_helpers.go adds `content_h1_to_h2` (EffectiveH1ToH2) to every category row, additive; categories_test.go's exact key lists gain it. `console/fix_headings.go`: bonfire command `journal:fix-headings` modeled on export_posts.go (flags `apply` Bare, `dry-run` implied default; Examples; English output exactly as P33-54), scanning posts (content_html default and translated) and categories (content), registered in plugin.go Commands(). `controllers/api/settings.go`: ShowSettings/UpdateSettings (validation via the model's Rules). `controllers/api/maintenance.go`: FixHeadings (posts and categories, answer {posts, categories, headings, dry_run}) and SetImageAlt handlers running in one transaction (dry_run rolls back), with RecordSaved per changed post and category through the existing emission helper; `classes/image_alt.go` rewrites the alt attribute of `<img>` whose src equals the change's src with the x/net/html tokenizer (escaping through the tokenizer, never string concatenation). Routes in the write group (throttle journal-api). MCP tools in mcp.go: `get_settings` (GET /settings), `update_settings` (PUT /settings), `fix_headings` (POST /fix-headings), `set_image_alt` (POST /images/alt, destructive true), permissions golem15.journal.manage_settings for the first three and golem15.journal.access_posts for set_image_alt. Tests first in headings_test.go: `TestHeadingConversion` (API, markdown, import, translated, category admin save with its notice), `TestFixHeadingsCommand` (posts and categories), `TestJournalSettingsTools`, `TestSetImageAlt`. Commit groups A, B, C as listed.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversion|TestFixHeadingsCommand|TestJournalSettingsTools|TestSetImageAlt' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or one of the four test names missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "journal:fix-headings" ../sm-journal-plugin/console/fix_headings.go` prints at least one line and `commands_documented_test.go` passes (command documented)
- `grep -n '"fix_headings"\|"set_image_alt"\|"get_settings"\|"update_settings"' ../sm-journal-plugin/mcp.go` prints four lines
- the refusal copy "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings." appears in console/fix_headings.go
- `grep -n ":categories categories" ../sm-journal-plugin/console/fix_headings.go` prints at least one line, and `grep -n "h1_converted_category" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each
- `grep -n '"content_h1_to_h2"' ../sm-journal-plugin/controllers/api/posts_helpers.go` prints at least one line
- the existing Journal API answer keys are unchanged apart from the additive content_h1_to_h2 (journal_api_matrix_test.go and categories_test.go pass)
</acceptance_criteria>
<done>Every Journal rich-text write path converts post content and category pillar text while the setting is on, stored content is only rewritten on request (both kinds), and Journal's settings and content fixes are MCP tools.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Descriptor facts for posts, categories and tags, and the journal-author content type</name>
<files>../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/content_facts.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/journal_descriptor_fields_test.go</files>
<read_first>
- ../sm-journal-plugin/classes/content_descriptors.go (describePosts, firstImages, describeCategories, describeTag, tagPages, TagPagesKey)
- ../sm-journal-plugin/classes/menu_item_types.go (MenuItemTypes, tag type registration, menuAlternates) and menu_item_types.go
- ../sm-journal-plugin/controllers/api/authors.go (author lookup by author_slug)
- ../sm-journal-plugin/content_descriptors_test.go (fixture style)
- summercms.go/modules/pact/content.go (33-01 fields)
</read_first>
<behavior>
- A published post with categories C1, C2 describes Parents [journal-category C1, journal-category C2] in that order, PublishedAt = published_at, Authors [{name, Ref journal-author <user id>}] while author pages are on and Ref.Type "" while off
- Its locales list Images: the featured image first (alt per P33-06), then body images in document order with alt, Decorative true for alt="" and width/height from attributes; Videos for each figure data-embed youtube; Headings from h1 to h6 with trimmed text
- TopHeading is 2 while content_h1_to_h2 is on and 0 while off; Kind article; Tools has edit, headings and image_alt with the journal_ tool names
- A child category describes Parents [its parent]; categories and tags have Kind collection
- A category with pillar text describes Headings from it, TopHeading 2 while content_h1_to_h2 is on and 0 while off, and Tools {headings: journal_fix_headings} (CONTEXT 2026-10-28 H1 conversion scope)
- journal-author: Records lists only authors with a published post, Describe answers the name as Title and the author URL per locale, Match is exact on the pattern, Kind profile; with golem15.journal.author_pages false Describe answers empty URLs and Match never matches
</behavior>
<action>
Per D-93, D-43, P33-06/P33-07 and the 2026-10-28 H1 conversion scope: `classes/content_facts.go` parses content_html once per locale with x/net/html into images (src, alt, decorative, width, height), YouTube videos and headings. Fill the 33-01 fields in describePosts, describeCategories and describeTag (same queries batched per page; no N+1 per record); describeCategories also parses the pillar text (content) for Headings and sets TopHeading from EffectiveH1ToH2 and Tools {headings: journal_fix_headings}, so the SEO plugin's CMS check h1_in_content (33-10) covers category pillar text too. Add the `journal-author` menu item type next to journal-tag (references and Resolve as the tag type does, Content descriptor with Model = the backend user morph type, Controller "", Kind profile), host key constant `AuthorPagesKey = "golem15.journal.author_pages"` (default true when unset), lang keys for its label. Tests first in journal_descriptor_fields_test.go (`TestJournalDescriptorFields`, `TestJournalAuthorType`). Commit groups A and B.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestJournalDescriptorFields|TestJournalAuthorType|TestRecordSavedDescribesNewURL' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestJournalDescriptorFields or TestJournalAuthorType missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "journal-author" ../sm-journal-plugin/classes/menu_item_types.go` prints at least one line
- `grep -n "PublishedAt:\|Parents:\|TopHeading:\|Tools:" ../sm-journal-plugin/classes/content_descriptors.go` prints at least four lines
- `grep -n "golem15.journal.author_pages" ../sm-journal-plugin/classes/content_descriptors.go` prints one line
</acceptance_criteria>
<done>Every Journal content type describes the facts the sitemaps, JSON-LD, breadcrumbs and the audit need, and author pages are a content type.</done>
</task>
<task type="auto">
<name>Task 4: Blog Editor J1–J4 on RequestOptions, link rel in the stored-HTML policy, slugged media names</name>
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/classes/media_names.go, ../sm-journal-plugin/classes/media_names_test.go, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/outline.ts, ../sm-journal-plugin/assets/src/blogeditor/linkform.ts, ../sm-journal-plugin/assets/src/blogeditor/imageform.ts, ../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/README.md</files>
<read_first>
- ../sm-journal-plugin/assets/src/blogeditor/{element.ts, extensions.ts, toolbar.ts, labels.ts, blogeditor.css, serializer.ts} and tests/blogeditor/{smoke.test.ts, corpus.test.ts}
- ../sm-journal-plugin/classes/blog_editor.go (BlogEditorTypes), format_html.go (newHTMLPolicy), media_store.go
- ../sm-journal-plugin/classes/editor_policy_test.go (TestEditorOutputPassesPolicy and the corpus)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C28 J1–J4, Blog Editor copy table, Shared rules, spacing exceptions)
- ../sm-journal-plugin/scripts/check-dist.sh
</read_first>
<action>
Per UI-SPEC C28 and D-51, D-81, D-83, D-85: BlogEditorTypes sets RequestOptions to {headingLevels: [2,3,4] or [1,2,3,4] by EffectiveH1ToH2, genericAnchors: GenericAnchors()}. element.ts reads `options.headingLevels` (default [2,3,4]) into the TipTap heading levels with a parse rule mapping `<h1>` to level 2 when 1 is absent; toolbar.ts adds `heading1` first when level 1 is allowed (label heading1). `outline.ts`: the J2 bar `blogeditor__outline-toggle` (aria-expanded, collapsed by default, never focused on load) and panel (ol of headings with level tag, flags flagSkipped, flagEmpty, flagLinkOnly, flagH1 only while level 1 is not allowed and Source mode holds an H1; outlineLinks with flagGeneric per link whose trimmed, lowercased, punctuation-stripped text is on genericAnchors; outlineEmpty; 12px indent per level below the first content level; 300 ms debounce; activating an item moves the caret to the heading; never edits content; long items wrap with a hanging indent). `linkform.ts`: J3 fieldset linkRel with relNofollow, relSponsored, relUgc enabled only for absolute http(s) URLs (relHint otherwise), Apply writes rel, internal links get none, genericWarning line (aria-live polite) without blocking Apply. `imageform.ts`: J4 queue form per file with fileName (slug rule on blur and Insert, extension shown fixed), altText (altRequired unless decorative), decorative checkbox (clears and disables alt, inserts alt=""), caption; Insert uploads the renamed file and inserts on the upload result. labels.ts gets every C28 key in en and pl (CLDR plurals for outlineCount and outlineProblems); blogeditor.css copies the editor metrics listed in UI-SPEC Spacing exceptions, light DOM, `--c-*` colours only. Server: format_html.go allows `rel` on `a` matching `^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$` (bluemonday AllowAttrs.Matching.OnElements); `classes/media_names.go` `MediaFileName(name string) string` implements the slug rule (smoke test `TestMediaFileName` in classes/media_names_test.go with the UI-SPEC example, an empty name and an 81-character name) and media_store.go applies it to uploads; editor-corpus.json gains a rel link and a decorative image so TestEditorOutputPassesPolicy pins them. Rebuild the bundle with `npm --prefix ../sm-journal-plugin run build` and commit assets/dist with the sources. README: the setting, its precedence, the command, the MCP tools, journal-author and the editor additions. Commit groups A to D as listed.
</action>
<verify>
<automated>npm --prefix ../sm-journal-plugin run test 2>&1 | tail -15</automated>
<fails_when>non-zero exit, or a "FAIL" or "failed" count above 0 in the vitest summary</fails_when>
<automated>bash ../sm-journal-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit (the committed bundle differs from a fresh build or typecheck fails)</fails_when>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestEditorOutputPassesPolicy|TestMediaFileName|TestBlogEditor' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line or "no tests to run"</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "headingLevels" ../sm-journal-plugin/assets/src/blogeditor/element.ts ../sm-journal-plugin/classes/blog_editor.go` prints a line in each
- `grep -n "nofollow|sponsored|ugc" ../sm-journal-plugin/classes/format_html.go` prints one line
- `grep -n "flagSkipped\|genericWarning\|decorative" ../sm-journal-plugin/assets/src/blogeditor/labels.ts` prints at least three lines
- `grep -n "func MediaFileName(" ../sm-journal-plugin/classes/media_names.go` prints one line, and a Go test asserts "Lwia Grzywa – w lesie!.JPG" → "lwia-grzywa-w-lesie.jpg"
- check-dist.sh passes after the dist commit
</acceptance_criteria>
<done>The editor follows the per-site heading setting, shows the outline and link warnings, writes rel on external links, names and describes images properly, and the server enforces the same rules.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin/API/MCP client → Journal write paths | untrusted HTML content and alt text |
| editor element ← typeOptions | per-request options shown to administrators |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-04 | Tampering | ConvertH1 / image alt rewrite | high | mitigate | both work on x/net/html tokens after the bluemonday policy, never on raw string replacement; attribute values are re-escaped by the tokenizer; TestSetImageAlt includes an alt with quotes and angle brackets |
| T-33-05 | Elevation of privilege | settings and maintenance routes | high | mitigate | backend-audience JWT plus golem15.journal.manage_settings / access_posts, 403 tests in TestJournalSettingsTools |
| T-33-06 | Tampering | link rel attribute | medium | mitigate | the policy accepts only the three rel tokens (regex), the corpus test pins it |
| T-33-07 | Tampering | uploaded file names | medium | mitigate | MediaFileName slugs server-side ([a-z0-9-], 80 characters) whatever the client sends |
| T-33-08 | Denial of service | fix-headings over a large site | low | accept | CLI/MCP operation by an administrator, one transaction per post, dry run by default |
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package; the bundle rebuilds from the committed lockfile |
</threat_model>
<verification>
- `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...` green after every commit.
- Docker suites: TestHeadingConversionTracer, TestHeadingConversion (posts and category pillar text), TestFixHeadingsCommand (posts and categories), TestJournalSettingsTools, TestSetImageAlt, TestJournalDescriptorFields, TestJournalAuthorType.
- `npm --prefix ../sm-journal-plugin run test` and `bash ../sm-journal-plugin/scripts/check-dist.sh` pass.
</verification>
<success_criteria>
- The H1 setting works in both modes on every Journal rich-text write path (post content and category pillar text), and the post and category answers expose it.
- The editor additions J1–J4 work as UI-SPEC C28 describes.
- Descriptors carry the new facts and author pages are a content type.
- Four Journal MCP tools exist.
</success_criteria>
## Artifacts this phase produces
- Config key: `golem15.journal.content_h1_to_h2` (default true), `golem15.journal.author_pages` (default true).
- Settings columns: `golem15_journal_settings.content_h1_to_h2`, `golem15_journal_settings.generic_anchors`; migration `202610280001_add_golem15_journal_settings_headings`.
- Go symbols: `classes.ConvertH1`, `classes.EffectiveH1ToH2`, `classes.GenericAnchors`, `classes.MediaFileName`, `classes.AuthorPagesKey`, API handlers `ShowSettings`, `UpdateSettings`, `FixHeadings`, `SetImageAlt`; `Plugin.ConfigFS`.
- Menu item type: `journal-author`.
- Routes: `GET /_journal/api/v1/settings`, `PUT /_journal/api/v1/settings`, `POST /_journal/api/v1/fix-headings` (answer `posts`, `categories`, `headings`, `dry_run`), `POST /_journal/api/v1/images/alt`; additive keys `content_h1_to_h2` (post Show and every category row of `GET /_journal/api/v1/categories`) and `notices` (write answers).
- CLI: `journal:fix-headings [--apply]` (posts and categories).
- MCP tools: `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt`.
- Files: `config/config.yaml`, `classes/headings.go`, `classes/journal_settings.go`, `classes/content_facts.go`, `classes/image_alt.go`, `classes/media_names.go`, `console/fix_headings.go`, `controllers/api/settings.go`, `controllers/api/maintenance.go`, `assets/src/blogeditor/{outline,linkform,imageform}.ts`, `headings_test.go`, `journal_descriptor_fields_test.go`.
- Lang keys: the C27 and C28 keys (`journal.tab_headings`, `journal.h1_to_h2_label`, `journal.h1_to_h2_comment`, `journal.generic_anchors_label`, `journal.generic_anchors_comment`, `editor.h1_converted`, and the element labels) with the P33-54 wording, plus `editor.h1_converted_category`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-02-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,396 @@
---
phase: 33-seo-output-audit-and-content
plan: 03
type: execute
wave: 2
depends_on: ["33-01"]
files_modified:
- ../sm-sitemap-plugin/contract/contract.go
- ../sm-sitemap-plugin/classes/slots.go
- ../sm-sitemap-plugin/classes/rebuild.go
- ../sm-sitemap-plugin/classes/writer.go
- ../sm-sitemap-plugin/classes/lastmod.go
- ../sm-sitemap-plugin/classes/types.go
- ../sm-sitemap-plugin/classes/listing.go
- ../sm-sitemap-plugin/classes/generator.go
- ../sm-sitemap-plugin/classes/generator_test.go
- ../sm-sitemap-plugin/classes/serialize.go
- ../sm-sitemap-plugin/classes/serialize_test.go
- ../sm-sitemap-plugin/classes/golden_test.go
- ../sm-sitemap-plugin/classes/testdata/full.golden
- ../sm-sitemap-plugin/classes/testdata/full.json
- ../sm-sitemap-plugin/classes/testdata/tracer.golden
- ../sm-sitemap-plugin/classes/testdata/tracer.json
- ../sm-sitemap-plugin/classes/testdata/urlto.golden
- ../sm-sitemap-plugin/classes/testdata/urlto.json
- ../sm-sitemap-plugin/classes/tiers.go
- ../sm-sitemap-plugin/classes/tiers_test.go
- ../sm-sitemap-plugin/classes/rewrite.go
- ../sm-sitemap-plugin/classes/rewrite_test.go
- ../sm-sitemap-plugin/classes/manifest.go
- ../sm-sitemap-plugin/classes/manifest_test.go
- ../sm-sitemap-plugin/classes/urlto.go
- ../sm-sitemap-plugin/classes/urlto_test.go
- ../sm-sitemap-plugin/classes/events.go
- ../sm-sitemap-plugin/assets/sitemap.xsl
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
- ../sm-sitemap-plugin/models/settings.go
- ../sm-sitemap-plugin/models/settings/fields.yaml
- ../sm-sitemap-plugin/models/settings_test.go
- ../sm-sitemap-plugin/models/url.go
- ../sm-sitemap-plugin/models/type_setting.go
- ../sm-sitemap-plugin/models/definition.go
- ../sm-sitemap-plugin/models/definition_item.go
- ../sm-sitemap-plugin/models/definition/fields.yaml
- ../sm-sitemap-plugin/models/definition_test.go
- ../sm-sitemap-plugin/models/registry.go
- ../sm-sitemap-plugin/updates/202610280001_create_golem15_sitemap_urls.go
- ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go
- ../sm-sitemap-plugin/updates/202610280003_create_golem15_sitemap_types.go
- ../sm-sitemap-plugin/updates/registry.go
- ../sm-sitemap-plugin/controllers/admin_registry.go
- ../sm-sitemap-plugin/controllers/settings.go
- ../sm-sitemap-plugin/controllers/settings/config_form.yaml
- ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm
- ../sm-sitemap-plugin/controllers/definitions.go
- ../sm-sitemap-plugin/controllers/definitions/config_form.yaml
- ../sm-sitemap-plugin/controllers/definitions_test.go
- ../sm-sitemap-plugin/admin.go
- ../sm-sitemap-plugin/extensions_settings.go
- ../sm-sitemap-plugin/listeners.go
- ../sm-sitemap-plugin/jobs.go
- ../sm-sitemap-plugin/schedule.go
- ../sm-sitemap-plugin/console/rebuild.go
- ../sm-sitemap-plugin/console/import_winter.go
- ../sm-sitemap-plugin/console/import.go
- ../sm-sitemap-plugin/console/import_winter_test.go
- ../sm-sitemap-plugin/import_test.go
- ../sm-sitemap-plugin/plugin.go
- ../sm-sitemap-plugin/routes.go
- ../sm-sitemap-plugin/routes_test.go
- ../sm-sitemap-plugin/sitemap_smoke_test.go
- ../sm-sitemap-plugin/sitemap_index_test.go
- ../sm-sitemap-plugin/config/config.yaml
- ../sm-sitemap-plugin/go.mod
- ../sm-sitemap-plugin/go.sum
- ../sm-sitemap-plugin/lang/en/lang.yaml
- ../sm-sitemap-plugin/lang/pl/lang.yaml
- ../sm-sitemap-plugin/README.md
autonomous: false
requirements: [SC-1, D-30, D-32, D-55, D-56, D-57, D-93, D-95, D-24, D-22]
estimate:
tokens: 160000
raw_tokens: 160000
tasks: 4
confidence: low
must_haves:
truths:
- "GET /sitemap.xml answers a sitemap index (application/xml) whose entries are per-type files sitemap-<slug>-<n>.xml with a lastmod each; each file lists at most file_size URLs (setting, default 1000, range 1 to 50 000) with <loc> and a real <lastmod>, and no <priority> or <changefreq> (D-30)"
- "File numbering is stable: URLs keep their file number across rebuilds, new URLs append to the last file of their group with room, a deleted, unpublished or excluded URL leaves a hole instead of shifting later files, and only a change of file_size renumbers (D-30, RESEARCH Pattern 2, Pitfall 5)"
- "A URL's lastmod moves only when its record is saved with a real change (RecordSaved with a non-empty Changes list outside the ignore list golem15.sitemap.lastmod_ignore); a no-op save, a counter update or a rebuild leaves it unchanged (D-30, RESEARCH Pitfall 4)"
- "Every URL of a record with more than one translated locale carries xhtml:link alternates for each translated locale including itself, plus x-default pointing to the default locale's URL; a locale without a URL gets no alternate and a single-locale record gets none (D-55, D-56, D-57)"
- "The sitemap consumes signpost.Records per content type (D-93) and asks the contract.Filter published by another plugin for each batch; without a Filter everything published is included, and with one, excluded URLs (noindex, canonical, redirect) leave the files and are counted by reason (D-95, D-24)"
- "The settings singleton (one tile, AdminSingleton) has the tabs Files (site_url, file_size, the S1 files partial), Content types (per signpost type include switch, file slug, HTML heading) and Additional addresses (url and lastmod rows); it replaces the Phase 17 definitions screen and the old settings tile (D-32, UI-SPEC C26, Q-4)"
- "The PHP-parity output is gone: priority tiers, the definitions screen, the update manifest, the festival BeforeAddItem/AddItem events, the PHP goldens and the XSL stylesheet (/sitemap.xsl answers 404); this removal is what resolves the todo .planning/todos/completed/sitemap-xslt-browser-deprecation.md (g15office w8kupqi3), which Jakub moved to completed on 2026-10-28 (D-32)"
- "A migration moves every url item of golem15_sitemap_definitions into the Additional addresses rows and frontend_url into site_url; the definitions table stays in the database unread, so a rollback keeps the data (D-32)"
- "The site URL of every <loc> is the setting site_url, else the config key golem15.sitemap.site_url, else app.url; the request's Host header is never used (CONTEXT 2026-10-28 accepted defaults: sitemap site URL precedence, RESEARCH Open Question 5)"
- "Rebuilds run as a conga job: debounced to one queued rebuild per golem15.sitemap.rebuild_delay_seconds (default 60) after record events, on every settings save, nightly through the scheduled command sitemap:rebuild (pact.HasSchedule, 03:00), and by hand with sitemap:rebuild"
- "The sitemap plugin publishes contract.Listing (Settings with SiteURL, IndexURL, Groups and the HTML-sitemap and IndexNow fields filled by 33-04; Entries paging the included URLs of a group) for other plugins, without depending on any of them (D-95)"
- "The index URL stays /sitemap.xml, so robots.txt Sitemap: lines and Search Console submissions need no change (D-22)"
- "S1 shows files.empty before the first build and the files.building callout while a rebuild job runs (UI-SPEC empty and loading: S1, sitemap rebuild)"
- "The settings form refuses an invalid file size, file slug, duplicate slug or site address with the errors.* copy of UI-SPEC C26 (UI-SPEC error: save refused)"
- {statement: "A file size change renumbers every file in one rebuild and the index lists the new files at once (documented on the field comment)", verification: backstop}
prohibitions:
- requirement_id: "D-95"
category: values
status: resolved
verification: test
statement: "The sitemap never lists a URL that the published Filter excluded, and never imports or names the SEO plugin"
- requirement_id: "D-30"
category: safety
status: resolved
verification: test
statement: "A rebuild never moves an existing URL to another file unless file_size changed"
artifacts:
- path: "../sm-sitemap-plugin/contract/contract.go"
provides: "Filter, Candidate, Decision, Group, Listing, ListingSettings, Entry, EntriesQuery, EntriesPage, reasons"
contains: "type Filter interface"
- path: "../sm-sitemap-plugin/classes/slots.go"
provides: "stable slot assignment"
contains: "func "
- path: "../sm-sitemap-plugin/classes/writer.go"
provides: "index and urlset writers on encoding/xml escaping"
contains: "xml.EscapeText"
- path: "../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go"
provides: "settings columns and the definitions url-item migration"
contains: "extra_urls"
- path: "../sm-sitemap-plugin/sitemap_index_test.go"
provides: "TestSitemapIndex, TestStableNumbering, TestLastmod, TestFilterExcludes, TestNoFilter, TestMigrateDefinitions, TestHreflangAlternates"
contains: "func TestSitemapIndex("
key_links:
- from: "../sm-sitemap-plugin/classes/rebuild.go"
to: "summercms.go/modules/signpost/content.go"
via: "Records per included content type, paged by Next"
pattern: "Records\\("
- from: "../sm-sitemap-plugin/classes/rebuild.go"
to: "../sm-sitemap-plugin/contract/contract.go"
via: "app.Lookup[contract.Filter]() decides each batch; absent means include everything"
pattern: "Lookup\\[contract\\.Filter\\]"
- from: "../sm-sitemap-plugin/listeners.go"
to: "../sm-sitemap-plugin/classes/lastmod.go"
via: "*pact.RecordSaved with non-ignored Changes sets lastmod; RecordSaved/RecordDeleted queue a debounced rebuild"
pattern: "\\*pact\\.RecordSaved"
---
<!-- planner-discipline-allow: priority -->
<!-- planner-discipline-allow: changefreq -->
<!-- planner-discipline-allow: sitemap.xsl -->
<!-- planner-discipline-allow: priority_tiers -->
<!-- planner-discipline-allow: cache_seconds -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan rewrites `sm-sitemap-plugin` to the D-30 format (the confirmed breaking change D-32): an index at `/sitemap.xml`, per-type files of a configurable size with stable numbering, real lastmod, hreflang alternates, and the filter contract the SEO plugin implements.
<objective>
Rewrite ../sm-sitemap-plugin (main, not pushed; 33-14 tags the next minor version, breaking):
- a published contract sub-package (`contract`) with the Filter other plugins implement and the Listing other plugins read (D-95);
- a stored slot table and a rebuild job over signpost content types (D-30, D-93);
- event-driven lastmod, hreflang alternates, index and file routes;
- the settings singleton replacing the Phase 17 screens, the migration of definitions, and the removal of the PHP-parity code (D-32).
Purpose: SC-1 ("every site serves a sitemap index with per-type files of a configurable size, stable numbering and real lastmod, consistent with noindex and robots.txt").
Output: commits in ../sm-sitemap-plugin. Image and News sitemaps, IndexNow, the HTML-sitemap API, the management API and MCP tools are plan 33-04.
**Planner decisions recorded here:**
- **P33-08 (contract package):** `git.golem15.com/golem15/sm-sitemap-plugin/contract` (package `contract`), not the plugin's root package, because importing the root package runs its `init` and registers the plugin. Types: `Candidate{TypeKey, RecordID, Locale, URL string}`; `Decision{Exclude bool; Reason string; Group string; LastMod *time.Time}`; `Group{Key string; Titles map[string]string}`; `Filter interface { Decide(ctx, []Candidate) ([]Decision, error); Groups(ctx) ([]Group, error) }`; reasons `ReasonNoindex = "noindex"`, `ReasonCanonical = "canonical"`, `ReasonRedirect = "redirect"`, `ReasonType = "type"`; `Listing interface { Settings(ctx) (ListingSettings, error); Entries(ctx, EntriesQuery) (EntriesPage, error) }`; `ListingSettings{SiteURL, IndexURL string; DefaultLocale string; Groups []GroupInfo; HTMLEnabled bool; HTMLPaths map[string]string; HTMLPageSize int; KeyFile string}`; `GroupInfo{Key, Slug string; Titles map[string]string; Count int}`; `EntriesQuery{Group, Locale, After string; Limit int}`; `Entry{Group, TypeKey, RecordID, Locale, URL, Title string; LastMod *time.Time}`; `EntriesPage{Entries []Entry; Next string; Total int}`. HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile stay zero until 33-04 adds their settings.
- **P33-09 (routes):** Go serves `GET /sitemap.xml` (index, literal root route) and `GET /_sitemap/files/{file}` (`Where("file", "sitemap-[a-z0-9-]+-[0-9]+\\.xml")`); the index's `<loc>` entries are the public root URLs `<site>/sitemap-<slug>-<n>.xml`, which the SEO layer (33-07) or an nginx rewrite maps to `/_sitemap/files/`. No root wildcard route is registered (it would capture every one-segment path of a site Go serves).
- **P33-10 (slot table):** `golem15_sitemap_urls` (id, grp, type_key, record_id, locale, url (path), title, file_no, first_seen, lastmod, alternates jsonb, images jsonb, excluded_reason, updated_at; unique (type_key, record_id, locale); index (grp, file_no)). Extra addresses use type_key `extra` and record_id = hex sha256 of the path. Group = per-type slug unless the Filter's Decision.Group overrides it; extra addresses go to `pages`. Default slug: last segment of the type key pluralised (y→ies, else +s unless it ends in s): journal-post → posts, journal-category → categories, journal-tag → tags, journal-author → authors, seo-page → pages.
- **P33-11 (reasons shown):** S1's left-out table shows noindex, canonical, redirect and type; unpublished and deleted rows are holes, not "left out".
- **P33-12 (debounce):** `golem15_sitemap_settings.rebuild_pending_until timestamptz`; an event sets it with a conditional UPDATE and enqueues one rebuild with that delay only when no pending time lies in the future; the job clears it.
</objective>
<assumption_delta_decision>
Signal: "chosen" (the ROADMAP's "per-type files of a configurable size").
Primary noun: a sitemap file is (group slug, file number) under a per-site file size; the single PHP file is gone.
Decision: promote. The per-type index becomes the only format (D-32); file size is a real setting (default 1000, 1 to 50 000, renumbering on change), not a constant.
Invariant test: TestStableNumbering (deletes leave holes, new URLs append, only a file-size change renumbers).
</assumption_delta_decision>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
@CLAUDE.md
<interfaces>
signpost (framework): Registry.ContentTypes() / ContentType(key) / Records(ctx, key, ContentList) / Describe(ctx, key, id, unprefixed); pact.ContentRecord{ID, Published, LastMod, PublishedAt, Parents, Authors, TopHeading, Locales[]{Locale, URL, Title, Description, Image, Words, Images, Videos, Headings}}; pact.RecordSaved{Controller, Model, ID, Locale, Created, Changes []RecordChange{Attribute, Locale, Old, New}}; pact.RecordDeleted.
conga: conga.Job(fn, conga.MaxAttempts(n)); conga.From(app).Dispatch(ctx, db, args, conga.DispatchOpts{Label, Delay}); Enqueue(ctx, db, args, conga.EnqueueOpts{Delay}); pact.HasJobs; pact.HasSchedule → []pact.ScheduledCommand{Command, Args, Cadence: pact.DailyAt(3, 0)}.
Analogs: ../sm-seo-plugin/jobs.go + classes/generate.go (job kind and dispatch after commit with lagoon.AfterCommit), ../sm-seo-plugin/extensions_settings.go (per-type settings tabs as a FieldsYAML document built at boot from signpost ContentTypes on the plugin's own singleton), ../sm-seo-plugin/controllers/settings.go (AdminSingleton linked from SettingsItem.Controller, D-104), ../sm-seo-plugin/classes/settings_write.go (settings singleton + history pattern).
Today's plugin: routes.go (GET /sitemap.xml, /sitemap.xsl, cache), models/settings.go (frontend_url, frontend_default_locale, updates_manifest_path, tiers), models/definition*.go, controllers/definitions.go, classes/{generator,serialize,tiers,rewrite,manifest,urlto,events}.go, console/import_winter.go (PHP rows), config/config.yaml (cache_seconds, priority_tiers), plugin.go (Requires nil, init registers).
XML namespaces: sitemap 0.9 http://www.sitemaps.org/schemas/sitemap/0.9, xhtml http://www.w3.org/1999/xhtml.
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path in ../sm-sitemap-plugin (`git -c safe.directory='*' -C ../sm-sitemap-plugin add <paths>`; deletions with `git rm`). Every commit keeps `go vet ./...` and `go test -short ./...` green there. The go.mod require stays on the existing framework tag with `replace => ../summercms.go` (already present); 33-14 bumps it.
- Resume check: `git -c safe.directory='*' -C ../sm-sitemap-plugin log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
| 2 | `feat: sitemap index and per-type files from a slot table (tracer)` | TestSitemapIndex passes |
| 3 | A `feat: sitemap settings singleton with content types and additional addresses`; B `feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL`; C `feat: migrate definitions into additional addresses` | settings and migration tests pass |
| 4 | A `feat: real lastmod from record events and debounced rebuilds`; B `feat: hreflang alternates and the sitemap filter contract`; C `docs: README for the per-type sitemap format` | all sitemap_index_test.go tests pass; plan complete |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the concrete public format of the new sitemaps</name>
<reversibility rating="one-way">The file names and the index URL are public: Search Console submissions, robots.txt and crawlers' caches follow them, and grzybyfunkcjonalne.pl moves to them in 33-13.</reversibility>
<decision>D-32 (the breaking switch) is decided; this confirms the concrete URLs, the XSL removal and the folding of the Phase 17 screen before code is written.</decision>
<context>
Proposed (RESEARCH A4, A5, Pitfall 8; UI-SPEC Q-4): (1) the index stays at /sitemap.xml, so GSC and robots.txt keep their URL; (2) files are /sitemap-<slug>-<n>.xml at the site root, slug per content type (posts, categories, tags, authors, pages; registry kinds offers, services, landing-pages), plus sitemap-images-<n>.xml and sitemap-news.xml in 33-04; Go serves them under /_sitemap/files/ and the SEO layer or nginx maps the root; (3) the XSL view is removed (Chrome 158 stops running XSLT on 2026-11-17; the HTML sitemap is the human view), which resolves the todo sitemap-xslt-browser-deprecation.md (g15office w8kupqi3; already moved to .planning/todos/completed on 2026-10-28); (4) the Phase 17 "Sitemap" definitions screen and the old settings tile become one settings singleton; url items migrate to Additional addresses; priority, change frequency, tiers and the update manifest are removed; the definitions table stays unread in the database.
</context>
<options>
<option id="as-proposed">
<name>Adopt (1) to (4)</name>
<pros>No GSC resubmission of the index; one admin screen; no XSL left to break</pros>
<cons>Editors lose the styled XML view (replaced by the HTML sitemap, 33-04/33-08)</cons>
</option>
<option id="keep-xsl">
<name>Adopt (1), (2), (4) and keep an XSL for the index only</name>
<pros>A styled view for browsers that still run XSLT</pros>
<cons>Breaks in Chrome from 2026-11-17; the completed todo would have to be reopened</cons>
</option>
<option id="other-names">
<name>Adopt with other file names or another index URL (stated in the answer)</name>
<pros>Names chosen by Jakub before they become public</pros>
<cons>A new index URL needs a GSC submission and robots.txt change on every site</cons>
</option>
</options>
<resume-signal>Reply "as-proposed", "keep-xsl" or "other-names: <names>"</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "a content type's published records appear in an index at /sitemap.xml that links per-type files of N URLs" — one path only</name>
<files>../sm-sitemap-plugin/contract/contract.go, ../sm-sitemap-plugin/models/url.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/updates/202610280001_create_golem15_sitemap_urls.go, ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/classes/slots.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/jobs.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/sitemap_index_test.go</files>
<read_first>
- ../sm-sitemap-plugin/plugin.go, routes.go, models/settings.go, updates/202610110002_create_golem15_sitemap_settings.go, testmain_test.go, sitemap_smoke_test.go
- ../sm-seo-plugin/jobs.go and classes/generate.go:14-28,168-177 (job kind, dispatch)
- ../sm-seo-plugin/fixture_test.go (a fake content type plugin in tests)
- summercms.go/modules/signpost/content.go (Records paging)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 2, Code Examples: index writer)
</read_first>
<action>
Per D-30, D-93 and P33-08 to P33-10: create the contract package with every type of P33-08. Migration 202610280001 creates golem15_sitemap_urls (P33-10); migration 202610280002 adds to golem15_sitemap_settings: site_url text, file_size integer NOT NULL DEFAULT 1000, default_locale text, extra_urls jsonb NOT NULL DEFAULT '[]', rebuild_pending_until timestamptz (the url-item copy is Task 3's). models/url.go and the new Settings fields. `classes/slots.go`: assign file numbers per group (keep existing numbers, append to the last file with room by first_seen then id, holes stay). `classes/rebuild.go`: `Rebuild(ctx, app) (RebuildResult, error)` walks signpost.ContentTypes, pages Records (Limit 500), keeps Published records with a URL per locale, upserts rows (title from ContentLocale.Title, lastmod initialised from the record's LastMod), marks vanished rows excluded "deleted", assigns slots in one transaction, and returns added/changed/removed URL lists (IndexNow in 33-04 uses them). `classes/writer.go`: index and urlset writers with bufio and `xml.EscapeText`, RFC 3339 UTC lastmod, no priority and no change frequency. jobs.go: `RebuildArgs` (kind `golem15.sitemap.rebuild`) with conga.MaxAttempts(3); plugin.go asserts pact.HasJobs. routes.go: replace the old handlers with the index (`GET /sitemap.xml`, absolute <loc> on the site URL per the precedence in must_haves, which is Jakub's 2026-10-28 accepted default in CONTEXT: setting, then golem15.sitemap.site_url, then app.url) and `GET /_sitemap/files/{file}` (P33-09), both reading the table, content type application/xml, X-Robots-Tag noindex on files, 404 for an unknown or empty file; keep the throttle bucket. `TestSitemapIndex` in sitemap_index_test.go (Docker): a fake content type with 5 published records and 1 draft, file_size 2 → after Rebuild the index lists sitemap-pages-1.xml to -3.xml with lastmod; file 1 lists 2 URLs with absolute <loc> and <lastmod>; the draft is absent; no priority or change frequency element anywhere; an unknown file answers 404. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSitemapIndex' -count=1 -v 2>&1 | tail -20</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSitemapIndex" line</fails_when>
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "type Filter interface\|type Listing interface" ../sm-sitemap-plugin/contract/contract.go` prints two lines
- `grep -n "xml.EscapeText" ../sm-sitemap-plugin/classes/writer.go` prints at least one line
- `grep -n "/_sitemap/files/{file}" ../sm-sitemap-plugin/routes.go` prints one line
- `grep -rn "party.Register\|func init" ../sm-sitemap-plugin/contract/` prints nothing
</acceptance_criteria>
<done>A real index and per-type files are served from the slot table after a rebuild job.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Settings singleton (Files, Content types, Additional addresses), removal of the PHP-parity code, and the definitions migration</name>
<files>../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/models/settings_test.go, ../sm-sitemap-plugin/models/type_setting.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/updates/202610280002_update_golem15_sitemap_settings.go, ../sm-sitemap-plugin/updates/202610280003_create_golem15_sitemap_types.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/classes/types.go, ../sm-sitemap-plugin/controllers/admin_registry.go, ../sm-sitemap-plugin/controllers/settings.go, ../sm-sitemap-plugin/controllers/settings/config_form.yaml, ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm, ../sm-sitemap-plugin/admin.go, ../sm-sitemap-plugin/extensions_settings.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter_test.go, ../sm-sitemap-plugin/import_test.go, ../sm-sitemap-plugin/routes_test.go, ../sm-sitemap-plugin/sitemap_smoke_test.go, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/sitemap_index_test.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/generator_test.go, ../sm-sitemap-plugin/classes/serialize.go, ../sm-sitemap-plugin/classes/serialize_test.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/full.golden, ../sm-sitemap-plugin/classes/testdata/full.json, ../sm-sitemap-plugin/classes/testdata/tracer.golden, ../sm-sitemap-plugin/classes/testdata/tracer.json, ../sm-sitemap-plugin/classes/testdata/urlto.golden, ../sm-sitemap-plugin/classes/testdata/urlto.json, ../sm-sitemap-plugin/classes/tiers.go, ../sm-sitemap-plugin/classes/tiers_test.go, ../sm-sitemap-plugin/classes/rewrite.go, ../sm-sitemap-plugin/classes/rewrite_test.go, ../sm-sitemap-plugin/classes/manifest.go, ../sm-sitemap-plugin/classes/manifest_test.go, ../sm-sitemap-plugin/classes/urlto.go, ../sm-sitemap-plugin/classes/urlto_test.go, ../sm-sitemap-plugin/classes/events.go, ../sm-sitemap-plugin/assets/sitemap.xsl, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/models/definition.go, ../sm-sitemap-plugin/models/definition_item.go, ../sm-sitemap-plugin/models/definition/fields.yaml, ../sm-sitemap-plugin/models/definition_test.go, ../sm-sitemap-plugin/controllers/definitions.go, ../sm-sitemap-plugin/controllers/definitions/config_form.yaml, ../sm-sitemap-plugin/controllers/definitions_test.go</files>
<read_first>
- ../sm-sitemap-plugin/admin.go, controllers/definitions.go, models/definition*.go, console/import_winter.go and import_test.go
- ../sm-seo-plugin/controllers/settings.go, controllers/settings/config_form.yaml, extensions_settings.go (singleton + per-type tabs pattern)
- ../sm-seo-plugin/controllers/rules/_seo_status.htm (partial view-model pattern with summer-callout and summer-table classes)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C26, S1, the sitemap copy table, Destructive actions: file size)
</read_first>
<behavior>
- The settings tile (icon network, existing category, label sitemap:settings.label) opens the singleton; the old Phase 17 tile and the definitions screen are gone
- Content types tab lists one block per registered signpost type in registration order: type_<key>_include (switch default on), type_<key>_slug (default per P33-10), type_<key>_title (mltext, default the type label); a duplicate slug refuses with errors.slug_taken, a slug outside [a-z0-9-] with errors.slug
- file_size outside 1..50000 refuses with errors.file_size; site_url refuses with the existing errors.frontend_url phrase when not an absolute http(s) URL
- Saving dispatches a rebuild; a type switched off moves its rows to excluded reason type
- Additional addresses rows (url path, optional lastmod) appear in the pages group with their lastmod
- Migration: definitions url items become extra_urls rows (url as given, no lastmod), frontend_url becomes site_url, frontend_default_locale becomes default_locale; reference items are dropped (content types cover them); the definitions table is untouched; Rollback clears the copied values only
- sitemap:import-winter writes PHP url items into extra_urls (the companion import path of the apps keeps working)
- GET /sitemap.xsl answers 404; the old cache config key is gone
</behavior>
<action>
Per D-32, UI-SPEC C26 and Task 1's answer: build the singleton controller `golem15.sitemap.settings` (AdminSingleton, settings tile via SettingsItem.Controller) with fields.yaml tabs Files (site_url text left, file_size number right, files partial S1 full), Additional addresses (extra_urls repeater, titleFrom url, rows url text left and lastmod datepicker right); the Content types tab is a FieldsYAML document built at boot from signpost ContentTypes by a self-extension on the singleton (the SEO plugin's extensions_settings.go pattern), stored in golem15_sitemap_types (type_key unique, include bool, slug, title via the translation writer) created by migration 202610280003. S1 partial `_sitemap_files.htm`: summer-table of files (index first; File in DM Mono linked to the public URL, Addresses, Last modified), then the Left out table by reason (P33-11), then the files.index_note callout; files.empty and files.building per C26. Remove the PHP-parity code and its tests (git rm the files listed in files_modified), the definitions controller, model and form, and the `cache_seconds` and `priority_tiers` config keys; config/config.yaml gets commented `site_url`, `lastmod_ignore` (default [updated_at, created_at, views, sort_order]) and `rebuild_delay_seconds` (60). Extend migration 202610280002 with the url-item copy (write the TestMigrateDefinitions case first). Retarget console/import_winter.go to extra_urls. Every copy key of the C26 sitemap table in both lang files. Commit groups A, B, C.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSettingsSingleton|TestMigrateDefinitions|TestImportWinter|TestSitemapIndex' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestSettingsSingleton or TestMigrateDefinitions missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-sitemap-plugin ls-files assets classes/testdata tests/php` prints nothing
- `grep -rn "priority_tiers\|cache_seconds" ../sm-sitemap-plugin --include=*.go --include=*.yaml` prints nothing
- `grep -n "golem15.sitemap.settings" ../sm-sitemap-plugin/controllers/settings.go` prints at least one line
- TestMigrateDefinitions asserts the copied extra_urls, the site_url and that golem15_sitemap_definitions still holds its rows
</acceptance_criteria>
<done>One settings screen manages the new format, the PHP-parity code is gone and existing sites' data migrates.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Real lastmod from record events, debounced and nightly rebuilds, hreflang alternates, and the Filter and Listing contract</name>
<files>../sm-sitemap-plugin/classes/lastmod.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/classes/listing.go, ../sm-sitemap-plugin/listeners.go, ../sm-sitemap-plugin/schedule.go, ../sm-sitemap-plugin/console/rebuild.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_index_test.go, ../sm-sitemap-plugin/commands_documented_test.go</files>
<read_first>
- summercms.go/modules/pact/record_events.go (RecordSaved, RecordChange, no-op saves fire with empty Changes)
- ../sm-seo-plugin/listeners.go (subscribing to *pact.RecordSaved / *pact.RecordDeleted on app.Events)
- ../sm-seo-plugin/classes/settings_write.go:301-313 (lagoon.AfterCommit enqueue)
- summercms.go/modules/pact/capabilities.go (HasSchedule, ScheduledCommand, DailyAt) and docs/plugins/scheduling.md
- ../sm-journal-plugin/classes/menu_item_types.go:53-55 (the single x-default alternate when translate has no locale)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pitfalls 3, 4, 5; Pattern 3)
</read_first>
<behavior>
- TestStableNumbering: 5 URLs at size 2 → files 1,1,2,2,3; delete the second → file 1 keeps one URL (hole), files 2 and 3 unchanged; add two → they go to file 3 then a new file 4; change size to 3 → renumbered 1,1,1,2,2,2
- TestLastmod: a save whose Changes touch title moves lastmod of that record's rows (only the changed locale's row for a translated change); a save with empty Changes or only updated_at/views does not; a rebuild never moves lastmod
- TestHreflangAlternates: a record with pl and en URLs → each url element carries xhtml:link for pl, en and x-default (default locale's URL); a record with only pl → none; a locale with an empty URL → absent
- TestFilterExcludes: with a published Filter returning Exclude noindex for one candidate and Group offers for another, the first leaves the files (counted under noindex), the second lands in sitemap-offers-1.xml; Filter.Groups titles reach Listing.Settings Groups
- TestNoFilter: without a Filter, everything published is listed
- TestDebouncedRebuild: three RecordSaved events within the delay queue exactly one rebuild job
- Listing.Entries pages a group's included URLs by after/limit with Total; Listing.Settings answers SiteURL, IndexURL and Groups with counts
</behavior>
<action>
Per D-30, D-55 to D-57, D-95 and P33-12: listeners.go subscribes `*pact.RecordSaved` and `*pact.RecordDeleted` (type looked up with signpost ContentTypeForModel); `classes/lastmod.go` sets lastmod = now on the record's rows when a change outside `golem15.sitemap.lastmod_ignore` exists (Locale "" → every locale row, else that locale's row), then requests a debounced rebuild (P33-12) after commit; RecordDeleted marks rows excluded deleted and requests a rebuild. rebuild.go: batch candidates per page through `app.Lookup[contract.Filter]()` when present (Decide; Exclude → excluded_reason; Group → grp; LastMod → initial lastmod for new rows only), excluded types (include off) → reason type; alternates per record = translated locales with a URL (≥2) plus x-default on default_locale (settings, else the record's first locale). writer.go emits xhtml:link alternates (namespace declared on the urlset only when used). `classes/listing.go` implements contract.Listing over the table and settings and plugin.go publishes it in Register with `app.Publish[contract.Listing]`. schedule.go: pact.HasSchedule with `{Command: "sitemap:rebuild", Cadence: pact.DailyAt(3, 0)}`; console/rebuild.go runs a rebuild synchronously (`--queue` dispatches the job instead), documented for commands_documented_test.go. README rewritten for the new format: routes, settings, contract package (with a Filter example), lastmod rules, config keys, the breaking change and the migration. Tests first per behavior list. Commit groups A, B, C.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestSitemapIndex|TestStableNumbering|TestLastmod|TestHreflangAlternates|TestFilterExcludes|TestNoFilter|TestDebouncedRebuild|TestMigrateDefinitions' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or any of the eight names missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Lookup\[contract.Filter\]" ../sm-sitemap-plugin/classes/rebuild.go` prints one line
- `grep -n "Publish\[contract.Listing\]\|Publish(" ../sm-sitemap-plugin/plugin.go` prints at least one line
- `grep -n "sitemap:rebuild" ../sm-sitemap-plugin/schedule.go ../sm-sitemap-plugin/console/rebuild.go` prints a line in each
- `grep -n "x-default" ../sm-sitemap-plugin/classes/rebuild.go ../sm-sitemap-plugin/classes/writer.go` prints at least one line
</acceptance_criteria>
<done>The sitemap is event-driven, multilingual, filterable by another plugin and readable through the published Listing.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| crawlers → /sitemap.xml, /_sitemap/files | anonymous, throttled reads |
| content data → XML output | titles and URLs written into XML |
| other plugins → contract.Filter | in-process decisions |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-09 | Tampering | XML writer | high | mitigate | every value through xml.EscapeText; only http(s) absolute locs built from the site URL setting/config, never from Host; TestSitemapIndex includes a title and path with &, < and quotes |
| T-33-10 | Denial of service | file and index routes | medium | mitigate | served from the table (no per-request generation), existing per-IP bucket kept, files route constrained by Where |
| T-33-11 | Information disclosure | unpublished records | high | mitigate | only Published records with a URL are stored as included; drafts tested absent |
| T-33-12 | Tampering | settings site_url | medium | mitigate | absolute http(s) validation, settings guarded by the existing sitemap permission |
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package |
</threat_model>
<verification>
- `go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...` after every commit.
- Docker suite: the eight sitemap_index_test.go tests plus TestSettingsSingleton and TestImportWinter.
</verification>
<success_criteria>
- /sitemap.xml is an index of per-type files with stable numbering, real lastmod and hreflang.
- The sitemap works with and without a Filter; the PHP-parity mode is gone and old data migrates.
</success_criteria>
## Artifacts this phase produces
- Package `git.golem15.com/golem15/sm-sitemap-plugin/contract`: `Filter`, `Candidate`, `Decision`, `Group`, `Listing`, `ListingSettings`, `GroupInfo`, `EntriesQuery`, `Entry`, `EntriesPage`, `ReasonNoindex`, `ReasonCanonical`, `ReasonRedirect`, `ReasonType`.
- Go symbols: `classes.Rebuild`, `classes.RebuildResult`, `RebuildArgs` (kind `golem15.sitemap.rebuild`), the Listing implementation.
- Tables/columns: `golem15_sitemap_urls`; `golem15_sitemap_types`; settings columns `site_url`, `file_size`, `default_locale`, `extra_urls`, `rebuild_pending_until`; migrations 202610280001 to 202610280003.
- Routes: `GET /sitemap.xml` (index), `GET /_sitemap/files/{file}`; removed `GET /sitemap.xsl`.
- Admin: controller `golem15.sitemap.settings` (singleton), partial `sitemap_files` (S1).
- CLI: `sitemap:rebuild [--queue]`; schedule entry `sitemap:rebuild` daily 03:00.
- Config keys: `golem15.sitemap.site_url`, `golem15.sitemap.lastmod_ignore`, `golem15.sitemap.rebuild_delay_seconds`; removed `golem15.sitemap.cache_seconds`, `golem15.sitemap.priority_tiers`.
- Tests: `TestSitemapIndex`, `TestStableNumbering`, `TestLastmod`, `TestHreflangAlternates`, `TestFilterExcludes`, `TestNoFilter`, `TestDebouncedRebuild`, `TestMigrateDefinitions`, `TestSettingsSingleton`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md` when done; note the Task 1 answer and, if g15office task w8kupqi3 is still open, that it can be closed as resolved by removal.
</output>

View File

@@ -0,0 +1,289 @@
---
phase: 33-seo-output-audit-and-content
plan: 04
type: execute
wave: 3
depends_on: ["33-03"]
files_modified:
- ../sm-sitemap-plugin/classes/images.go
- ../sm-sitemap-plugin/classes/news.go
- ../sm-sitemap-plugin/classes/indexnow.go
- ../sm-sitemap-plugin/classes/htmlgroups.go
- ../sm-sitemap-plugin/classes/rebuild.go
- ../sm-sitemap-plugin/classes/slots.go
- ../sm-sitemap-plugin/classes/writer.go
- ../sm-sitemap-plugin/classes/listing.go
- ../sm-sitemap-plugin/models/settings.go
- ../sm-sitemap-plugin/models/settings/fields.yaml
- ../sm-sitemap-plugin/models/url.go
- ../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go
- ../sm-sitemap-plugin/updates/registry.go
- ../sm-sitemap-plugin/controllers/settings.go
- ../sm-sitemap-plugin/controllers/settings/config_form.yaml
- ../sm-sitemap-plugin/controllers/settings/_indexnow_status.htm
- ../sm-sitemap-plugin/controllers/api/auth.go
- ../sm-sitemap-plugin/controllers/api/html.go
- ../sm-sitemap-plugin/controllers/api/settings.go
- ../sm-sitemap-plugin/controllers/api/indexnow.go
- ../sm-sitemap-plugin/jobs.go
- ../sm-sitemap-plugin/mcp.go
- ../sm-sitemap-plugin/routes.go
- ../sm-sitemap-plugin/plugin.go
- ../sm-sitemap-plugin/config/config.yaml
- ../sm-sitemap-plugin/lang/en/lang.yaml
- ../sm-sitemap-plugin/lang/pl/lang.yaml
- ../sm-sitemap-plugin/README.md
- ../sm-sitemap-plugin/sitemap_extras_test.go
- .planning/phases/33-seo-output-audit-and-content/COVERAGE.md
autonomous: true
requirements: [SC-1, D-31, D-34, D-24, D-10, D-30]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 3
confidence: low
must_haves:
truths:
- "With images_enabled (default on) the index lists sitemap-images-<n>.xml files whose <url> entries carry the page <loc> and one <image:image><image:loc> per featured or body image of that page, at most 1000 images per page, numbered stably like the per-type files (D-31)"
- "With news_enabled (default off) the index lists sitemap-news.xml with at most 1000 articles (content Kind article) first published in the last two days, filtered by news_sources (article types and their categories; empty = every article type), each with publication name (news_publication, else app.name), language, publication_date from PublishedAt and title (D-31, RESEARCH Pitfall 9)"
- "With indexnow_enabled (default off) the plugin generates a 32-character hex key with crypto/rand on save, serves it at GET /_sitemap/indexnow/<key>.txt (the SEO layer maps the root /<key>.txt), and after each rebuild posts the added, changed and removed URLs (at most 10 000 per request) to https://api.indexnow.org/indexnow through a fetchguard client limited to that host; 403, 422 and 429 answers are recorded and shown in S2, 429 retries through the job's attempts (D-31)"
- "Generating a new key (indexnow_new_key on save) makes the old key file answer 404 at once (UI-SPEC Destructive actions)"
- "No IndexNow request is ever sent outside the production environment"
- "GET /_sitemap/api/v1/html?locale= answers the HTML-sitemap overview (groups in the order pages, offers, services, categories as a tree, posts newest first, tags, authors, other types; each group with its title in the locale, total, the first html_page_size entries and more=true when larger) and GET /_sitemap/api/v1/html/{group}?locale=&page= one page of a group; both list exactly the indexable set of the XML sitemaps (no excluded URL), answer 404 when the HTML sitemap is off, for an unknown group or a page past the last, and carry X-Robots-Tag noindex (D-34, D-24)"
- "The settings singleton gains the tabs Images and News, IndexNow (with the S2 indexnow_status partial) and HTML sitemap (html_enabled default on, html_path per locale default /mapa-strony pl and /sitemap en, html_page_size 50 to 500 default 150), with the C26 copy and errors.html_path / errors.page_size refusals (UI-SPEC C26)"
- "contract.Listing.Settings answers HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile, so the SEO plugin and the layer read them without importing sitemap internals (D-95)"
- "Every sitemap admin operation is an MCP tool: sitemap_get_settings (GET /_sitemap/api/v1/settings), sitemap_update_settings (PUT) and sitemap_rebuild (POST /_sitemap/api/v1/rebuild), listed only with the sitemap permission (D-10, UI-SPEC MCP parity)"
- "S2 shows indexnow.no_key before a key exists and indexnow.never before the first notification, then the key, its URL and the last notification (UI-SPEC empty: S2)"
- "S2 shows the indexnow.err_key, err_host or err_rate callout matching the last 403, 422 or 429 answer (UI-SPEC error: IndexNow)"
- "The external API this plan integrates (IndexNow) has its capability matrix in COVERAGE.md, every opt-out with a reason"
- {statement: "A HTML-sitemap group larger than html_page_size shows its first page plus the sitemap.all link and its group page is paginated (UI-SPEC zero-one-many: large groups; layer rendering in 33-08)", verification: backstop}
prohibitions:
- requirement_id: "D-31"
category: safety
status: resolved
verification: test
statement: "IndexNow never submits a URL whose host differs from the site URL's host, and never sends a request outside production"
artifacts:
- path: "../sm-sitemap-plugin/classes/indexnow.go"
provides: "key generation, ping job body, answer recording"
contains: "api.indexnow.org"
- path: "../sm-sitemap-plugin/classes/htmlgroups.go"
provides: "HTML sitemap groups, order, tree and paging"
contains: "func "
- path: "../sm-sitemap-plugin/mcp.go"
provides: "sitemap MCP tools"
contains: "pact.HasMCP"
- path: "../sm-sitemap-plugin/sitemap_extras_test.go"
provides: "TestImageSitemap, TestNewsSitemap, TestIndexNow, TestHTMLGroups, TestSitemapTools"
contains: "func TestIndexNow("
- path: ".planning/phases/33-seo-output-audit-and-content/COVERAGE.md"
provides: "IndexNow capability matrix"
contains: "INTEGRATE"
key_links:
- from: "../sm-sitemap-plugin/classes/rebuild.go"
to: "../sm-sitemap-plugin/classes/indexnow.go"
via: "a rebuild's added/changed/removed URLs dispatch the IndexNow job when enabled in production"
pattern: "IndexNowArgs"
- from: "../sm-sitemap-plugin/classes/indexnow.go"
to: "summercms.go/modules/fetchguard"
via: "fetchguard.NewClient(Policy{Mode: AllowHostsMode, AllowHosts: [api.indexnow.org]}).PostJSON"
pattern: "fetchguard\\.NewClient"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan adds the image and News sitemaps, IndexNow pings, the data API behind the HTML sitemap page, and MCP tools for the sitemap settings.
<objective>
Extend ../sm-sitemap-plugin (after 33-03; main, not pushed):
- image sitemap files and the News sitemap from the 33-01 descriptor facts (D-31);
- IndexNow: key, key file, ping job from rebuild diffs, S2 status (D-31);
- the HTML-sitemap groups API that the layer's page renders (D-34, layer page in 33-08);
- the remaining settings tabs, the management API and the MCP tools (D-10);
- keeping `COVERAGE.md` (the IndexNow capability matrix written at planning time) in sync with what is built.
Purpose: SC-1 (image and News sitemaps, IndexNow) and the data side of the HTML sitemap.
Output: commits in ../sm-sitemap-plugin; a planning-docs commit here only if COVERAGE.md has to change.
**Planner decisions recorded here:**
- **P33-13 (image slots):** `golem15_sitemap_urls.image_file_no` (stable numbering of rows that have images, same rules as file_no) plus `published_at`, `kind` and `parent_refs jsonb` columns (for News and the category tree), filled by the rebuild; migration 202610280004 adds them with the media, News, IndexNow and HTML settings columns: images_enabled bool default true, news_enabled bool default false, news_publication text, news_sources jsonb default '[]', indexnow_enabled bool default false, indexnow_key text, indexnow_last_at timestamptz, indexnow_last_count int, indexnow_last_status int, html_enabled bool default true, html_path (translatable through the translation writer, default value pl /mapa-strony and en /sitemap), html_page_size int default 150.
- **P33-14 (IndexNow request):** body `{host, key, urlList}`; no keyLocation (the key file is served at the root, so it covers every URL of the host); endpoint `https://api.indexnow.org/indexnow`; 200 and 202 are success; 400, 403, 422 recorded without retry; 429 makes the job return an error so conga retries (MaxAttempts 5).
- **P33-15 (news sources values):** taglist values `type:<type key>` and `category:<type key>:<id>`; a News candidate matches when its type or one of its Parents is listed.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-03: contract.Listing / ListingSettings{SiteURL, IndexURL, DefaultLocale, Groups, HTMLEnabled, HTMLPaths, HTMLPageSize, KeyFile}; classes.Rebuild → RebuildResult{Added, Changed, Removed []string}; golem15_sitemap_urls (grp, type_key, record_id, locale, url, title, file_no, first_seen, lastmod, alternates, images, excluded_reason); routes GET /sitemap.xml, GET /_sitemap/files/{file}; settings singleton golem15.sitemap.settings with Files, Content types, Additional addresses tabs.
fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, Timeout}, cfg) → Client.PostJSON(ctx, url, header, payload) (*Result, error); WithTransport(ctx, rt) routes a test's requests to an httptest handler.
Management API analog: ../sm-seo-plugin/controllers/api/auth.go `manage(...)` (cabana.ResolveBackend + cabana.Allows) and mcp.go (pact.MCP tools bound to routes, pier prefixes the plugin id).
Specs (cited in 33-RESEARCH.md): image sitemap only image:image + image:loc, ≤1000 per url; News: articles from the last two days, ≤1000 news:news per sitemap; IndexNow key 8–128 [A-Za-z0-9-], ≤10 000 URLs per POST.
Copy: UI-SPEC C26 tables (tabs, images, News, IndexNow, S2, HTML sitemap, errors).
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path in ../sm-sitemap-plugin; COVERAGE.md committed here as planning docs. Every commit keeps vet and `test -short` green.
- Resume check: `git -c safe.directory='*' -C ../sm-sitemap-plugin log --format=%s | grep -Fx '<subject>'`; here `git log --format=%s | grep -Fx 'docs(33-04): update the IndexNow coverage matrix'` (only when group C was needed).
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: image sitemap files (tracer)` | TestImageSitemap passes |
| 2 | A `feat: Google News sitemap`; B `feat: IndexNow key, key file and pings`; C (here, only if the matrix changed) `docs(33-04): update the IndexNow coverage matrix` | News and IndexNow tests pass |
| 3 | A `feat: HTML sitemap groups API`; B `feat: sitemap management API and MCP tools`; C `docs: images, News, IndexNow, HTML sitemap and MCP in README` | plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "a post's featured and body images appear in sitemap-images-1.xml linked from the index" — one path only</name>
<files>../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/models/url.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/classes/images.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/slots.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/sitemap_extras_test.go</files>
<read_first>
- ../sm-sitemap-plugin/classes/{rebuild,slots,writer}.go and sitemap_index_test.go (33-03)
- summercms.go/modules/pact/content.go (ContentLocale.Images, Image)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Code Examples: urlset with images; State of the Art: image tags)
</read_first>
<action>
Per D-31 and P33-13: migration 202610280004 adds the P33-13 columns; the rebuild stores each locale row's images (featured Image first, then Images, absolute on the site URL when relative, deduplicated, at most 1000) and kind, published_at, parent_refs; slots.go assigns image_file_no to rows with images (stable, holes kept). writer.go emits the image urlset (namespace http://www.google.com/schemas/sitemap-image/1.1, only image:image and image:loc). The index adds sitemap-images-<n>.xml when images_enabled and the file has rows; the files route serves them. `TestImageSitemap`: a post with a featured image and two body images → sitemap-images-1.xml lists its page with three image:loc; images_enabled false removes the files from the index; a page with 1200 images lists 1000. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestImageSitemap|TestSitemapIndex' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestImageSitemap" line</fails_when>
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "sitemap-image/1.1" ../sm-sitemap-plugin/classes/writer.go` prints one line
- `grep -n "image_file_no" ../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go` prints one line
</acceptance_criteria>
<done>Images of every listed page reach Google through the image sitemap files.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Google News sitemap and IndexNow (key, key file, ping job, S2)</name>
<files>../sm-sitemap-plugin/classes/news.go, ../sm-sitemap-plugin/classes/indexnow.go, ../sm-sitemap-plugin/classes/rebuild.go, ../sm-sitemap-plugin/classes/writer.go, ../sm-sitemap-plugin/controllers/api/indexnow.go, ../sm-sitemap-plugin/controllers/settings.go, ../sm-sitemap-plugin/controllers/settings/config_form.yaml, ../sm-sitemap-plugin/controllers/settings/_indexnow_status.htm, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/jobs.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/sitemap_extras_test.go, .planning/phases/33-seo-output-audit-and-content/COVERAGE.md</files>
<read_first>
- summercms.go/modules/fetchguard/README.md (Client, PostJSON, WithTransport, AllowHostsMode)
- ../sm-seo-plugin/classes/robots.go (how Staging()/non-production is detected) and classes/service.go Staging()
- ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm (partial style from 33-03)
- .planning/phases/33-seo-output-audit-and-content/COVERAGE.md (the matrix this task implements)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C26 Images and News, IndexNow tabs, S2 copy)
</read_first>
<behavior>
- TestNewsSitemap: articles published 1 hour and 47 hours ago are listed, one published 49 hours ago is not; a non-article type is never listed; news_sources [category:journal-category:3] keeps only articles with that parent; more than 1000 candidates list the newest 1000; news_enabled false: no sitemap-news.xml in the index and the file answers 404
- TestIndexNow (fake transport): enabling and saving generates a 32-hex key; GET /_sitemap/indexnow/<key>.txt answers the key as text/plain; another name answers 404; after a rebuild with one new and one removed URL the job posts {host, key, urlList[2]} to api.indexnow.org; 202 recorded as last status; 403 and 422 recorded without retry; 429 makes the job fail so it retries; indexnow_new_key replaces the key and the old file answers 404; with the app environment not production no request is sent; a URL of another host is never in urlList
</behavior>
<action>
Per D-31, P33-14 and P33-15: `classes/news.go` builds the News urlset (namespace http://www.google.com/schemas/sitemap-news/0.9) from rows with kind article and published_at in the last 48 hours, newest first, limit 1000, publication name news_publication else config app.name, language = the row's locale. `classes/indexnow.go`: `GenerateKey()` (crypto/rand, hex, 32 characters), `IndexNowArgs` (kind golem15.sitemap.indexnow, URLs) dispatched by the rebuild after commit when enabled, in production and the diff is non-empty (chunks of 10 000), the job posting through a fetchguard client (AllowHostsMode, AllowHosts [api.indexnow.org], Timeout 10 s) and recording indexnow_last_at, _count, _status. `controllers/api/indexnow.go` serves the key file (`GET /_sitemap/indexnow/{file}` with Where `[A-Za-z0-9-]{8,128}\.txt`). Settings tabs Images and News (images_enabled, news_enabled, news_publication and news_sources hidden by trigger while news_enabled is unchecked; news_sources taglist mode array, customTags false, options from article types and their categories) and IndexNow (indexnow_enabled, partial S2 `_indexnow_status.htm`, indexnow_new_key checkbox hidden while disabled; the form's save generates the key). Config keys documented: `indexnow_endpoint` is not configurable (fixed host). COVERAGE.md was written at planning time (IndexNow capability matrix); keep it in sync: when the implementation drops, adds or changes a capability, update its row (every OPT-OUT keeps a reason) and commit it here as group C, otherwise there is no group C. Tests first. Commit groups A and B (plugin), C only if COVERAGE.md changed.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestNewsSitemap|TestIndexNow' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestNewsSitemap or TestIndexNow missing from the "--- PASS" lines</fails_when>
<automated>grep -c "| OPT-OUT |" .planning/phases/33-seo-output-audit-and-content/COVERAGE.md</automated>
<fails_when>output is 0, or the command exits non-zero because the file is missing</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "crypto/rand" ../sm-sitemap-plugin/classes/indexnow.go` prints one line
- `grep -n "AllowHostsMode" ../sm-sitemap-plugin/classes/indexnow.go` prints one line
- `grep -n "sitemap-news/0.9" ../sm-sitemap-plugin/classes/news.go` prints one line
- every OPT-OUT row in COVERAGE.md has a non-empty reason column
</acceptance_criteria>
<done>News and IndexNow work behind their switches, never outside production, and the integration's coverage is on record.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: HTML-sitemap groups API, the HTML sitemap tab, Listing fields, management API and MCP tools</name>
<files>../sm-sitemap-plugin/classes/htmlgroups.go, ../sm-sitemap-plugin/classes/listing.go, ../sm-sitemap-plugin/controllers/api/auth.go, ../sm-sitemap-plugin/controllers/api/html.go, ../sm-sitemap-plugin/controllers/api/settings.go, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/mcp.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_extras_test.go</files>
<read_first>
- ../sm-seo-plugin/controllers/api/auth.go (manage wrapper), mcp.go, mcp_test.go
- ../sm-seo-plugin/routes.go:79-85 (noindex wrapper) and Buckets()
- ../sm-sitemap-plugin/classes/listing.go (33-03)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L3 group order and markup data, C26 HTML sitemap tab, MCP parity)
</read_first>
<behavior>
- TestHTMLGroups: overview for pl lists groups in the L3 order with titles from type_<key>_title in pl, categories nested by parent, posts newest first, others alphabetical; a group with 160 entries at page size 150 shows 150 and more=true; GET /html/posts?page=2 answers the rest with pages=2; page 3 → 404; unknown group → 404; html_enabled false → 404 for both; excluded URLs never appear; answers carry X-Robots-Tag noindex
- Listing.Settings answers HTMLEnabled, HTMLPaths {pl:/mapa-strony, en:/sitemap}, HTMLPageSize and KeyFile (<key>.txt when IndexNow is on)
- TestSitemapTools: GET/PUT /_sitemap/api/v1/settings and POST /_sitemap/api/v1/rebuild need a backend principal with the sitemap permission (401/403 otherwise); PUT validates like the form; the MCP catalog lists get_settings, update_settings and rebuild bound to those routes
</behavior>
<action>
Per D-34, D-10 and UI-SPEC L3/C26: `classes/htmlgroups.go` builds the overview and group pages from the slot table (included rows only, locale filter, L3 order, category tree from parent_refs, paging by html_page_size); `controllers/api/html.go` serves `GET /_sitemap/api/v1/html` and `GET /_sitemap/api/v1/html/{group}` (public, noindex header, throttle bucket `sitemap-html` 300/min per client IP behind trusted proxies). HTML sitemap tab fields (html_enabled switch, html_path mltext with the defaults, html_page_size number) and errors.html_path / errors.page_size validation. listing.go fills the HTML and KeyFile fields. Management API (`controllers/api/auth.go` manage wrapper modeled on the SEO plugin's; `controllers/api/settings.go` show/update; rebuild dispatch) under `/_sitemap/api/v1` with bucket `sitemap-api`; `mcp.go` declares get_settings, update_settings, rebuild (pact.HasMCP, guard backend, the plugin's existing permission). README documents every route, setting, config key and tool. Tests first. Commit groups A, B, C.
</action>
<verify>
<automated>go -C ../sm-sitemap-plugin test ./... -run 'TestHTMLGroups|TestSitemapTools|TestImageSitemap|TestNewsSitemap|TestIndexNow' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestHTMLGroups or TestSitemapTools missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "/_sitemap/api/v1/html" ../sm-sitemap-plugin/routes.go ../sm-sitemap-plugin/README.md` prints a line in each
- `grep -n '"get_settings"\|"update_settings"\|"rebuild"' ../sm-sitemap-plugin/mcp.go` prints three lines
- `grep -n "X-Robots-Tag" ../sm-sitemap-plugin/controllers/api/html.go ../sm-sitemap-plugin/routes.go` prints at least one line
</acceptance_criteria>
<done>The HTML sitemap has its data API, the sitemap settings are complete and every sitemap operation is reachable through MCP.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| sitemap job → api.indexnow.org | outbound HTTP from the server |
| visitors → HTML-sitemap API | anonymous reads |
| admin/MCP → management API | authenticated writes |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-13 | Elevation of privilege (SSRF) | IndexNow ping | high | mitigate | fetchguard AllowHostsMode with the single host api.indexnow.org, HTTPS only, no redirects followed; URL list filtered to the site host (TestIndexNow) |
| T-33-14 | Information disclosure | IndexNow key | low | accept | the key is public by design (served at the root); rotation through indexnow_new_key |
| T-33-15 | Spoofing | staging pings | medium | mitigate | no request outside production (TestIndexNow non-production case) |
| T-33-16 | Denial of service | HTML-sitemap API | medium | mitigate | table reads with paging capped at html_page_size ≤500, per-IP bucket sitemap-html |
| T-33-17 | Elevation of privilege | management API and MCP | high | mitigate | cabana.ResolveBackend + permission check on every route, 401/403 tests in TestSitemapTools |
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package (fetchguard and crypto/rand are framework/stdlib) |
</threat_model>
<verification>
- `go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./...` after every commit.
- Docker suite: TestImageSitemap, TestNewsSitemap, TestIndexNow, TestHTMLGroups, TestSitemapTools, plus the 33-03 suite.
- COVERAGE.md has no OPT-OUT row without a reason.
</verification>
<success_criteria>
- Image and News sitemaps and IndexNow work behind their switches.
- The HTML-sitemap API answers exactly the indexable set.
- Sitemap settings are manageable through MCP.
</success_criteria>
## Artifacts this phase produces
- Columns: `golem15_sitemap_urls.image_file_no`, `published_at`, `kind`, `parent_refs`; settings `images_enabled`, `news_enabled`, `news_publication`, `news_sources`, `indexnow_enabled`, `indexnow_key`, `indexnow_last_at`, `indexnow_last_count`, `indexnow_last_status`, `html_enabled`, `html_path`, `html_page_size`; migration 202610280004.
- Go symbols: `classes.GenerateKey`, `IndexNowArgs` (kind `golem15.sitemap.indexnow`), the News and image writers, HTML group builder, API handlers for html, settings, rebuild and the key file.
- Routes: `GET /_sitemap/indexnow/{file}`, `GET /_sitemap/api/v1/html`, `GET /_sitemap/api/v1/html/{group}`, `GET /_sitemap/api/v1/settings`, `PUT /_sitemap/api/v1/settings`, `POST /_sitemap/api/v1/rebuild`; files `sitemap-images-<n>.xml`, `sitemap-news.xml`.
- Throttle buckets: `sitemap-html`, `sitemap-api`.
- MCP tools: `sitemap_get_settings`, `sitemap_update_settings`, `sitemap_rebuild`.
- Partial: `indexnow_status` (S2).
- Files: `classes/images.go`, `classes/news.go`, `classes/indexnow.go`, `classes/htmlgroups.go`, `controllers/api/{auth,html,settings,indexnow}.go`, `mcp.go`, `sitemap_extras_test.go`, `.planning/phases/33-seo-output-audit-and-content/COVERAGE.md` (written at planning time, kept in sync here).
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-04-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,341 @@
---
phase: 33-seo-output-audit-and-content
plan: 05
type: execute
wave: 4
depends_on: ["33-04"]
files_modified:
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- ../sm-seo-plugin/go.work
- ../sm-seo-plugin/plugin.go
- ../sm-seo-plugin/menu_item_types.go
- ../sm-seo-plugin/classes/sitemap_filter.go
- ../sm-seo-plugin/classes/registry.go
- ../sm-seo-plugin/classes/site.go
- ../sm-seo-plugin/classes/llms.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/classes/batch.go
- ../sm-seo-plugin/classes/issues.go
- ../sm-seo-plugin/classes/robots.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/models/page.go
- ../sm-seo-plugin/models/page/columns.yaml
- ../sm-seo-plugin/models/page/fields.yaml
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/models/robots.go
- ../sm-seo-plugin/models/registry.go
- ../sm-seo-plugin/updates/202610280002_create_golem15_seo_pages.go
- ../sm-seo-plugin/updates/202610280003_add_golem15_seo_links_and_llms.go
- ../sm-seo-plugin/updates/registry.go
- ../sm-seo-plugin/extensions_robots.go
- ../sm-seo-plugin/extension/_seo_status.htm
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/pages.go
- ../sm-seo-plugin/controllers/pages/config_list.yaml
- ../sm-seo-plugin/controllers/pages/config_form.yaml
- ../sm-seo-plugin/controllers/pages/config_filter.yaml
- ../sm-seo-plugin/controllers/pages/_page_status.htm
- ../sm-seo-plugin/controllers/api/registry.go
- ../sm-seo-plugin/controllers/api/site.go
- ../sm-seo-plugin/controllers/api/llms.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/config/config.yaml
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.yaml
- ../sm-seo-plugin/README.md
- ../sm-seo-plugin/fixture_test.go
- ../sm-seo-plugin/sitemap_filter_test.go
- ../sm-seo-plugin/registry_test.go
autonomous: true
requirements: [SC-1, SC-3, D-95, D-24, D-33, D-12, D-22, D-25, D-83, D-94, D-10]
estimate:
tokens: 160000
raw_tokens: 160000
tasks: 3
confidence: low
must_haves:
truths:
- "sm-seo-plugin publishes a contract.Filter (sm-sitemap-plugin/contract) and requires golem15.sitemap: a URL whose effective SEO is noindex, whose canonical points elsewhere or that redirects leaves the XML and HTML sitemaps with the reason noindex, canonical or redirect; the non-production lock never excludes a URL, so staging and production list the same set (D-95, D-24)"
- "The page registry (SEO › Pages, controller golem15.seo.pages) holds frontend pages with an address per locale, a kind (page, offer, service, landing), a name, a parent, a content-changed date and a note; it is the signpost content type seo-page, so each page gets the SEO tab, resolves ok, and is listed in the sitemaps grouped by kind into sitemap-pages, sitemap-offers, sitemap-services and sitemap-landing-pages files (D-33, D-30)"
- "The frontend server reports its static routes with POST /_seo/api/v1/registry/routes (bearer golem15.seo.route_report_token, else notfound_token): unknown paths are inserted with origin reported and kind page, known ones get reported_at, nothing is ever deleted or overwritten, and a reported page missing from a newer report shows the Not on the site badge and the P9 warning (D-33, UI-SPEC C21)"
- "A registry address that another content type owns refuses with pages.url_owned; a duplicate, an invalid address, a missing default-locale address and a parent loop refuse with their C21 copy (UI-SPEC C21, error: save refused)"
- "GET /_seo/api/v1/site answers the site facts the layer needs: site_url, default_locale, locales, unprefixed_locale, links.new_tab, html_sitemap {enabled, paths, page_size}, routes [{type, pattern}] for the drift check, root_files (verification file names and the IndexNow key file) and llms (D-97 support, D-20c, D-103)"
- "With llms_enabled (robots.txt screen, AI bots tab, default off) GET /llms.txt serves the site's indexable pages from the sitemaps grouped like the HTML sitemap, headed by llms_intro or the default meta description; off, it answers 404 (D-25, UI-SPEC C25)"
- "robots.txt Sitemap: lines name the sitemap index from the sitemap plugin's Listing (/sitemap.xml) unless golem15.seo.robots_sitemaps overrides them; per-type files are never listed (D-22)"
- "The SEO settings get a Links tab with links_new_tab (switch, default on), nofollow_domains and sponsored_domains (one domain per line, subdomains included, settings.domain_invalid on a bad line); all three are MCP-writable through seo_update_settings and reach the resolve answer as outbound {new_tab, nofollow_domains, sponsored_domains} (2026-10-28 layer options: links_new_tab; D-83)"
- "The batch conflict noindex_published fires only when the URL is currently listed in a sitemap file (read through contract.Listing), and a CMS issue sitemap_blocked (Error, tool seo_update_robots) reports sitemap URLs that the production robots.txt blocks for Googlebot (D-12, D-24)"
- "Every SEO write (admin, MCP, batch, import, undo) purges the rendered-site cache after commit through the framework's site-cache purge, so the layer's cached resolve answers and pages refresh (33-CONTEXT Claude's discretion: purge with D-97)"
- "Pages tools seo_list_pages, seo_create_page, seo_update_page and seo_delete_page exist and TestEveryAdminOperationHasTool maps every Pages operation (D-10)"
- "SEO › Pages sits between URL rules and robots.txt in the side menu with the icon files, behind golem15.seo.manage (UI-SPEC C15)"
- "The Pages list shows pages.list_empty when empty (UI-SPEC empty: Pages list)"
- "The SEO tab's P1 shows status.excluded_title with the matching status.excluded_noindex, _canonical, _redirect or _type text when the page is left out of the sitemaps (UI-SPEC partial: page excluded)"
- "A reported page missing from the newest report keeps its data, shows the negative badge pages.state_missing and the P9 warning pages.status_missing, and is never deleted automatically (UI-SPEC partial: registry page missing)"
- {statement: "Two route reports in a row with the same list change only reported_at; a report from a server with a stale build never removes an editor's title, kind or SEO values", verification: backstop}
prohibitions:
- requirement_id: "D-33"
category: safety
status: resolved
verification: test
statement: "The route report never deletes a registry page and never overwrites a value an editor set"
- requirement_id: "D-95"
category: values
status: resolved
verification: judgment
statement: "The SEO plugin reads sitemap data only through the published contract package; the sitemap plugin keeps working without SEO"
artifacts:
- path: "../sm-seo-plugin/classes/sitemap_filter.go"
provides: "contract.Filter implementation on the resolver"
contains: "Decide("
- path: "../sm-seo-plugin/classes/registry.go"
provides: "page registry service, signpost descriptor, route report"
contains: "func "
- path: "../sm-seo-plugin/controllers/api/site.go"
provides: "GET /_seo/api/v1/site"
contains: "func "
- path: "../sm-seo-plugin/sitemap_filter_test.go"
provides: "TestSitemapFilterTracer, TestFilterReasons, TestSitemapBlockedIssue, TestNoindexPublishedConflict"
contains: "func TestSitemapFilterTracer("
- path: "../sm-seo-plugin/registry_test.go"
provides: "TestRegistry, TestRouteReport, TestSiteEndpoint, TestLLMSTxt, TestPurgeAfterWrite"
contains: "func TestRegistry("
key_links:
- from: "../sm-seo-plugin/plugin.go"
to: "../sm-sitemap-plugin/contract/contract.go"
via: "Register publishes the Filter with app.Publish[contract.Filter]; Requires includes golem15.sitemap"
pattern: "contract\\.Filter"
- from: "../sm-seo-plugin/classes/history.go"
to: "summercms.go/modules/cooler/sitecache.go"
via: "each recorded write schedules the site-cache purge after commit"
pattern: "Purge"
- from: "../sm-seo-plugin/menu_item_types.go"
to: "../sm-seo-plugin/classes/registry.go"
via: "the seo-page MenuItemType's Content descriptor (Records, Describe, Match) reads the registry"
pattern: "seo-page"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan connects the SEO plugin to the new sitemaps and gives the frontend layer its site-level facts: the sitemap filter, the page registry with the route report, the site endpoint, llms.txt, the Sitemap: line, the Links settings (new tab, nofollow and sponsored domains) and cache purging after SEO writes.
<objective>
Extend ../sm-seo-plugin (main, not pushed):
- implement and publish `contract.Filter` (D-95, D-24) and require golem15.sitemap;
- the page registry: model, admin screen (C21), signpost type, route report endpoint, MCP tools (D-33, D-10);
- `GET /_seo/api/v1/site`, `GET /llms.txt` (D-25), robots `Sitemap:` from the index (D-22);
- the Links tab (`links_new_tab`, nofollow and sponsored domains) and `outbound` in the resolve answer (2026-10-28 layer options, D-83);
- the refined batch conflict and the `sitemap_blocked` CMS issue (D-12, D-24);
- site-cache purge after every SEO write.
Purpose: SC-1 (sitemaps consistent with noindex and robots.txt), D-33, and the contract the layer (33-07/33-08) builds on.
Output: commits in ../sm-seo-plugin. This plan runs after 33-04 (not beside it) because its tests mount the sibling sm-sitemap-plugin checkout through go.work, which 33-04 edits; a concurrent edit there would break this plan's builds. go.mod requires `git.golem15.com/golem15/sm-sitemap-plugin` at its existing tag with the sibling replaced in go.work; 33-14 bumps the require to the new sitemap tag.
**Planner decisions recorded here:**
- **P33-16 (resolve answer, additive, `costly`):** `Result.Outbound *Outbound` with JSON `outbound: {new_tab: bool, nofollow_domains: [string], sponsored_domains: [string]}`, present on ok answers. No existing key changes. 33-06 and 33-11 add further keys; the layer treats absent keys as empty.
- **P33-17 (site answer, `costly`):** `GET /_seo/api/v1/site` (public group, noindex, bucket seo-resolve) answers `{site_url, default_locale, locales: [..], unprefixed_locale, links: {new_tab}, html_sitemap: {enabled, paths: {locale: path}, page_size}, routes: [{type, pattern}], root_files: [..], llms: bool}`; 33-06 adds `pagination` and `home_crumb`.
- **P33-18 (registry table):** `golem15_seo_pages` (id, url text unique (default-locale path; other locales through the translation writer), title, kind page|offer|service|landing, origin manual|reported|mcp, parent_id, content_updated_at date, reported_at timestamptz, note, timestamps); settings column `routes_reported_at`. Groups: Filter.Groups answers pages, offers, services, landing-pages with titles from `pages.kind_*` per locale; Decide sets Group from the page kind.
- **P33-19 (purge):** the purge is scheduled where history records a write (classes/history.go), so every write path that records history purges once per transaction (kind `seo`, slug = the URL or `*` for settings).
- **P33-20 (route report token):** config `golem15.seo.route_report_token` (empty uses `notfound_token`; both empty → 404), constant-time comparison, body `{routes: [path...]}` of at most 2000 paths of at most 2048 bytes each.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/33-seo-output-audit-and-content/33-03-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-04-SUMMARY.md
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-03: package git.golem15.com/golem15/sm-sitemap-plugin/contract — Filter{Decide(ctx, []Candidate) ([]Decision, error); Groups(ctx) ([]Group, error)}; Candidate{TypeKey, RecordID, Locale, URL}; Decision{Exclude, Reason, Group, LastMod}; Group{Key, Titles}; reasons noindex/canonical/redirect/type; Listing{Settings(ctx) (ListingSettings, error); Entries(ctx, EntriesQuery) (EntriesPage, error)}; ListingSettings{SiteURL, IndexURL, DefaultLocale, Groups, HTMLEnabled, HTMLPaths, HTMLPageSize, KeyFile}.
sm-seo-plugin (Phase 32): classes.Service (NewService(app), SiteURL, Staging, DefaultLocale), Resolver.Resolve(ctx, rawURL) (Result{Action, Status, Location, URL, Locale, Staging, SEO, Sources, TypeKey, RecordID}) and ResolveRecord; classes/settings_write.go (allowed columns, SettingsValues, SetSettingsValue: four places per new column); classes/history.go (Record, WithVia); classes/batch.go (conflict kinds incl. noindex_published); classes/issues.go (Issue{Kind, URL, Locale, Details, Tool}, toolSetURLSEO = "seo_set_url_seo"); classes/robots.go (served body, robots_sitemaps); extensions_robots.go (fields on the robots singleton); controllers/redirects.go + config_*.yaml (list/form pattern); controllers/api/notfound.go (bearer token pattern); mcp.go (tools), operations.go (AdminOperations), operations_test.go (TestEveryAdminOperationHasTool, TestMCPReadToolsAreGET); routes.go (public group with noindex(), management group with BucketAPI); fixture_test.go (fake content plugin acme.pages, fake TranslationWriter); testmain_test.go (Docker Postgres).
cooler: SiteCachePurger (Purge(ctx, kind, slug)) and the after-commit purge helper in modules/cooler/sitecache.go (read the file for the exact name).
Copy: UI-SPEC C15, C20 Links tab, C21, C25 and their copy tables.
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group; `assets/dist` untouched (no element in this plan).
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: sitemap filter excludes noindex, canonicalised and redirected URLs (tracer)` | TestSitemapFilterTracer passes |
| 2 | A `feat: page registry with its admin screen and signpost type`; B `feat: route report endpoint`; C `feat: MCP tools for registry pages` | registry tests and the operations parity pass |
| 3 | A `feat: site endpoint, llms.txt and the sitemap index in robots.txt`; B `feat: links settings and outbound rules in resolve`; C `feat: sitemap-aware batch conflict and the sitemap_blocked issue`; D `feat: purge the site cache after SEO writes`; E `docs: registry, site endpoint, llms.txt and links in README` | plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor sets a page to noindex and it leaves the XML sitemap with reason noindex" — one path only</name>
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/go.work, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/classes/sitemap_filter.go, ../sm-seo-plugin/fixture_test.go, ../sm-seo-plugin/sitemap_filter_test.go</files>
<read_first>
- ../sm-sitemap-plugin/contract/contract.go and classes/rebuild.go (33-03)
- ../sm-seo-plugin/plugin.go, classes/resolver.go (ResolveRecord), fixture_test.go, testmain_test.go
- summercms.go/modules/backpack/services.go (Publish keyed by the type argument; duplicate is an error)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 3)
</read_first>
<action>
Per D-95, D-24 and P33-18: add the require of git.golem15.com/golem15/sm-sitemap-plugin (its current tag) and `replace git.golem15.com/golem15/sm-sitemap-plugin => ../sm-sitemap-plugin` in go.work; plugin.go adds "golem15.sitemap" to Requires and publishes `classes.SitemapFilter` with `app.Publish[contract.Filter]` in Register. `classes/sitemap_filter.go`: Decide resolves each candidate's record (ResolveRecord by type, id, locale; batched through one DB handle) ignoring the non-production lock: robots noindex → Exclude noindex; canonical whose path differs from the candidate's → Exclude canonical; a redirect source → Exclude redirect; else include. Groups answers nothing yet (Task 2 adds the registry kinds). The test fixture app mounts the sitemap plugin (import of its package in the test) next to the fake content plugin. `TestSitemapFilterTracer` (Docker): two published fake pages; set the first to noindex through the SEO tab admin save; run the sitemap rebuild; the sitemap file lists only the second; the excluded row's reason is noindex; the same holds with the app environment set to staging. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSitemapFilterTracer' -count=1 -v 2>&1 | tail -20</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestSitemapFilterTracer" line</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "golem15.sitemap" ../sm-seo-plugin/plugin.go` prints at least one line
- `grep -n "sm-sitemap-plugin/contract" ../sm-seo-plugin/classes/sitemap_filter.go ../sm-seo-plugin/plugin.go` prints a line in each
- `grep -n "sm-sitemap-plugin =>" ../sm-seo-plugin/go.work` prints one line
</acceptance_criteria>
<done>The SEO plugin decides what the sitemap lists, and a noindex page leaves the sitemap.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Page registry (model, admin screen, signpost type seo-page, sitemap groups), route report endpoint and Pages MCP tools</name>
<files>../sm-seo-plugin/models/page.go, ../sm-seo-plugin/models/page/columns.yaml, ../sm-seo-plugin/models/page/fields.yaml, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280002_create_golem15_seo_pages.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/registry.go, ../sm-seo-plugin/classes/sitemap_filter.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/pages.go, ../sm-seo-plugin/controllers/pages/config_list.yaml, ../sm-seo-plugin/controllers/pages/config_form.yaml, ../sm-seo-plugin/controllers/pages/config_filter.yaml, ../sm-seo-plugin/controllers/pages/_page_status.htm, ../sm-seo-plugin/controllers/api/registry.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/registry_test.go</files>
<read_first>
- ../sm-seo-plugin/controllers/redirects.go, controllers/redirects/config_{list,form,filter}.yaml, models/redirect.go and models/redirect/{columns,fields}.yaml
- ../sm-seo-plugin/controllers/api/notfound.go (token check) and controllers/api/redirects.go (management CRUD)
- ../sm-seo-plugin/classes/owner.go (FindOwner via MatchPath) and classes/rules.go
- ../sm-journal-plugin/classes/menu_item_types.go (a MenuItemType with a Content descriptor)
- ../sm-seo-plugin/operations.go and operations_test.go
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C21, Pages copy table, Destructive actions)
</read_first>
<behavior>
- Create /kontakt (kind page, title Kontakt) → it resolves ok with the SEO tab values; it is a seo-page record in signpost; the sitemap lists it in sitemap-pages-1.xml; a kind offer page lands in sitemap-offers-1.xml
- A page with en address /contact and pl /kontakt has two locale URLs and hreflang alternates; an empty en address means no en URL (UI-SPEC Q-15)
- Address /blog/lwia-grzywa owned by a fake record → 422 pages.url_owned naming the type and title; duplicate → pages.url_taken; "kontakt" → pages.url_invalid; empty default-locale address → pages.url_required; parent loop → pages.parent_loop
- Route report with the right token and ["/", "/kontakt", "/o-nas"] inserts /o-nas (origin reported, kind page, title derived from the last segment) and updates reported_at of the others; a later report without /o-nas marks it missing (badge, P9 warning) and deletes nothing; wrong token 401; no token configured 404; 2001 paths 422; a path owned by a content type is skipped
- Deleting a page deletes its SEO entries (the existing RecordDeleted cleanup) and it leaves the sitemaps on the next rebuild
- Filter.Groups answers pages, offers, services, landing-pages with the kind titles per locale; Decide groups registry candidates by kind
- MCP lists list_pages, create_page, update_page, delete_page; TestEveryAdminOperationHasTool passes with the Pages controller
</behavior>
<action>
Per D-33, D-10, UI-SPEC C21 and P33-18/P33-20: migration 202610280002 creates golem15_seo_pages and the settings column routes_reported_at; models/page.go with url and title translatable through the translation writer (as the SEO settings' ml fields are), Rules and the C21 validation (FormBeforeCreate/Update hook in the controller translating :type/:title params, the Phase 32 pattern); `classes/registry.go`: CRUD service with history, `ReportRoutes(ctx, paths) (ReportResult, error)`, and the descriptor functions; `menu_item_types.go` registers MenuItemType `seo-page` (label pages.name, Content{Controller golem15.seo.pages, Model page morph, Kind page, Records, Describe (per-locale URLs, Title, LastMod content_updated_at, Parents [seo-page parent], Published true), Match exact}). Pages screen: config_list.yaml (title pages.list_title, recordUrl update, 20 per page, checkboxes, setup, toolbar [create, delete], noRecordsMessage pages.list_empty, defaultSort url asc, row state negative pages.state_missing for reported pages with reported_at before routes_reported_at), columns and filters per C21, form per C21 with P9 `_page_status.htm` (pages.status_reported plain or pages.status_missing warning, nothing for manual pages); navigation item Pages (icon files) after URL rules, permission golem15.seo.manage. `controllers/api/registry.go`: `POST /_seo/api/v1/registry/routes` (public group, bucket seo-notfound, P33-20 token) and the management CRUD `GET/POST /_seo/api/v1/pages`, `PUT/DELETE /_seo/api/v1/pages/{id}`; MCP tools list_pages, create_page, update_page, delete_page (destructive) and the AdminOperations rows for controller golem15.seo.pages. Config key `route_report_token` documented in config/config.yaml. Every C21 copy key in both lang files. Tests first in registry_test.go (`TestRegistry`, `TestRouteReport`). Commit groups A, B, C.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRegistry|TestRouteReport|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestSitemapFilterTracer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestRegistry, TestRouteReport or TestEveryAdminOperationHasTool missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"seo-page"' ../sm-seo-plugin/menu_item_types.go ../sm-seo-plugin/classes/registry.go` prints at least one line
- `grep -n "/registry/routes" ../sm-seo-plugin/routes.go` prints one line and `grep -n "subtle.ConstantTimeCompare" ../sm-seo-plugin/controllers/api/registry.go` prints one line
- `grep -n '"list_pages"\|"create_page"\|"update_page"\|"delete_page"' ../sm-seo-plugin/mcp.go` prints four lines
- TestRouteReport asserts that a later report deletes nothing and keeps editor-set titles
</acceptance_criteria>
<done>Frontend pages live in a CMS registry with the SEO tab, sitemap groups by kind, a safe route report and MCP tools.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Site endpoint, llms.txt, robots Sitemap: from the index, Links settings with outbound rules in resolve, sitemap-aware conflict and issue, purge after writes</name>
<files>../sm-seo-plugin/classes/site.go, ../sm-seo-plugin/classes/llms.go, ../sm-seo-plugin/classes/robots.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/batch.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/models/robots.go, ../sm-seo-plugin/updates/202610280003_add_golem15_seo_links_and_llms.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/extensions_robots.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/controllers/api/site.go, ../sm-seo-plugin/controllers/api/llms.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/registry_test.go, ../sm-seo-plugin/sitemap_filter_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/settings_write.go (allowed columns ~36-41, SettingsValues 46-69, SetSettingsValue 72+, history and after-commit job 301-313)
- ../sm-seo-plugin/classes/robots.go (served body, Sitemap lines), classes/batch.go (conflicts), classes/issues.go, classes/history.go
- ../sm-seo-plugin/extensions_robots.go and extension/_seo_status.htm
- summercms.go/modules/cooler/sitecache.go (the after-commit purge helper)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C20 Links tab, C25, SEO tab additions copy for status.excluded_*)
</read_first>
<behavior>
- TestSiteEndpoint: /_seo/api/v1/site answers every P33-17 key; root_files lists each verification file name and, when the sitemap's IndexNow is on, its key file; routes lists each content type's layered pattern; X-Robots-Tag noindex
- TestLLMSTxt: off → 404; on → text/plain with "# <site name>", the intro (or default description) as a quote line, then one "## <group title>" section per non-empty group with "- [title](absolute url)" lines; excluded URLs never appear
- Robots: without robots_sitemaps override the served file has exactly "Sitemap: <site>/sitemap.xml"; with the override, the configured lines
- Links tab: links_new_tab default true; nofollow_domains "example.com\nhttps://x.pl/a" → 422 settings.domain_invalid naming line 2; the resolve answer of an ok page has outbound {new_tab: true, nofollow_domains: [example.com], sponsored_domains: []}; seo_update_settings with links_new_tab false turns it off
- TestNoindexPublishedConflict: a batch noindex on a URL that is in a sitemap file reports noindex_published; on a URL not listed (type switched off) it does not
- TestSitemapBlockedIssue: robots.txt "Disallow: /blog/" with a listed /blog/x → issue sitemap_blocked (Error, tool seo_update_robots, details.line)
- TestPurgeAfterWrite: an SEO tab save, a redirect create through MCP and a settings save each post one purge after commit; a rolled-back batch posts none
- P1 shows status.excluded_title with the reason text for a noindex page and for a page whose type is off in the sitemap settings
</behavior>
<action>
Per D-22, D-24, D-25, D-83, the 2026-10-28 layer options and P33-16/P33-17/P33-19: migration 202610280003 adds settings links_new_tab boolean NOT NULL DEFAULT true, nofollow_domains text, sponsored_domains text, and robots llms_enabled boolean NOT NULL DEFAULT false, llms_intro text. Settings: the four-place pattern of settings_write.go for the three Links columns with domain validation (lowercase host names, one per line, no scheme or path), the Links tab in models/settings/fields.yaml (links_new_tab switch right with settings.links_new_tab_comment; nofollow_domains and sponsored_domains textarea small left/right with settings.domains_comment); robots singleton fields llms_enabled and llms_intro (hide trigger) through extensions_robots.go. `classes/site.go` builds the P33-17 answer (Listing.Settings via app.Lookup[contract.Listing], verification names, signpost patterns); `controllers/api/site.go` serves it; `classes/llms.go` + `controllers/api/llms.go` serve the literal root route `GET /llms.txt` (bucket seo-robots). robots.go reads the index URL from Listing.Settings when `robots_sitemaps` is not set in config (the config default line changes to a comment explaining the fallback). resolver.go adds Outbound (P33-16). batch.go: noindex_published only when a contract.Listing entry lists the URL. issues.go: kind `sitemap_blocked` (Error, tool seo_update_robots) using the grobotstxt matcher against the production body. history.go schedules the after-commit purge (P33-19). partials.go/_seo_status.htm: the excluded callout from Filter decisions for the record. README: routes, config keys, the registry, the site answer, llms.txt, Links and purge. Tests first. Commit groups A to E.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSiteEndpoint|TestLLMSTxt|TestNoindexPublishedConflict|TestSitemapBlockedIssue|TestPurgeAfterWrite|TestRobots|TestSettingsLinks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestSiteEndpoint, TestLLMSTxt, TestNoindexPublishedConflict, TestSitemapBlockedIssue or TestPurgeAfterWrite missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"/llms.txt"\|"/site"' ../sm-seo-plugin/routes.go` prints two lines
- `grep -n "links_new_tab" ../sm-seo-plugin/classes/settings_write.go ../sm-seo-plugin/models/settings/fields.yaml ../sm-seo-plugin/classes/resolver.go` prints a line in each
- `grep -n "sitemap_blocked" ../sm-seo-plugin/classes/issues.go` prints at least one line
- existing resolve keys are unchanged: TestResolverPrecedence and the Phase 32 resolve tests pass
</acceptance_criteria>
<done>The layer has a site endpoint, robots and llms.txt follow the sitemap, outbound link rules are settings, conflicts and issues know the sitemap, and every SEO write refreshes the rendered site.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| frontend server → route report | token-authenticated server-to-server POST |
| visitors → /site, /llms.txt | anonymous reads |
| admin/MCP → registry and settings | authenticated writes |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-18 | Tampering | route report poisoning the registry | high | mitigate | bearer token with constant-time comparison, size caps, insert-only (never delete or overwrite editor values), owned paths skipped; TestRouteReport |
| T-33-19 | Information disclosure | /site answer | medium | mitigate | answers only public facts (site URL, locales, patterns, public root file names); no token, no unpublished data; reviewed against P33-17 in TestSiteEndpoint |
| T-33-20 | Information disclosure | llms.txt | medium | mitigate | built from the sitemap's included set only (noindex/unpublished never present), off by default |
| T-33-21 | Elevation of privilege | Pages admin and MCP | high | mitigate | golem15.seo.manage on every operation; TestEveryAdminOperationHasTool and the manage wrapper |
| T-33-22 | Tampering | domain lists | low | mitigate | validated host names only; rendered by the layer as rel tokens, never as markup |
| T-33-23 | Denial of service | purge after writes | low | mitigate | one purge per transaction after commit, coalesced by the framework purger |
| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package; sm-sitemap-plugin is a first-party sibling module |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
- Docker suite: TestSitemapFilterTracer, TestRegistry, TestRouteReport, TestSiteEndpoint, TestLLMSTxt, TestNoindexPublishedConflict, TestSitemapBlockedIssue, TestPurgeAfterWrite and the Phase 32 suites.
- `go -C ../sm-sitemap-plugin test -short ./...` still passes (sitemap without SEO).
</verification>
<success_criteria>
- Sitemaps leave out noindex, canonicalised and redirected URLs through the published filter.
- The page registry works from the admin, MCP and the route report.
- The site endpoint, llms.txt, robots Sitemap:, the Links settings and purge after writes work.
</success_criteria>
## Artifacts this phase produces
- Go symbols: `classes.SitemapFilter` (Decide, Groups), registry service with `ReportRoutes`, `classes.Outbound` and `Result.Outbound`, site and llms builders, API handlers for registry, pages, site and llms.
- Menu item type: `seo-page` (Kind page).
- Tables/columns: `golem15_seo_pages`; settings `routes_reported_at`, `links_new_tab`, `nofollow_domains`, `sponsored_domains`; robots `llms_enabled`, `llms_intro`; migrations 202610280002, 202610280003.
- Routes: `POST /_seo/api/v1/registry/routes`, `GET /_seo/api/v1/site`, `GET /llms.txt`, `GET/POST /_seo/api/v1/pages`, `PUT/DELETE /_seo/api/v1/pages/{id}`; resolve answer key `outbound`.
- Admin: controller `golem15.seo.pages` (Pages), partial `page_status` (P9), Links tab, llms fields on the robots screen; issue kind `sitemap_blocked`.
- MCP tools: `seo_list_pages`, `seo_create_page`, `seo_update_page`, `seo_delete_page`.
- Config key: `golem15.seo.route_report_token`.
- Tests: `TestSitemapFilterTracer`, `TestRegistry`, `TestRouteReport`, `TestSiteEndpoint`, `TestLLMSTxt`, `TestNoindexPublishedConflict`, `TestSitemapBlockedIssue`, `TestPurgeAfterWrite`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,338 @@
---
phase: 33-seo-output-audit-and-content
plan: 06
type: execute
wave: 5
depends_on: ["33-05"]
files_modified:
- ../sm-seo-plugin/classes/schema/graph.go
- ../sm-seo-plugin/classes/schema/nodes.go
- ../sm-seo-plugin/classes/schema/validate.go
- ../sm-seo-plugin/classes/schema/custom.go
- ../sm-seo-plugin/classes/breadcrumbs.go
- ../sm-seo-plugin/classes/alternates.go
- ../sm-seo-plugin/classes/pagination.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/normalize.go
- ../sm-seo-plugin/classes/site.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/entries.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/manage.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/updates/202610280004_add_golem15_seo_publisher_pagination.go
- ../sm-seo-plugin/updates/registry.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/extension/_seo_status.htm
- ../sm-seo-plugin/extension/_seo_effective.htm
- ../sm-seo-plugin/controllers/settings/_publisher_preview.htm
- ../sm-seo-plugin/controllers/settings/config_form.yaml
- ../sm-seo-plugin/controllers/api/graph.go
- ../sm-seo-plugin/controllers/api/seo.go
- ../sm-seo-plugin/field_types.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/config/config.yaml
- ../sm-seo-plugin/assets/src/elements/schema.ts
- ../sm-seo-plugin/assets/src/elements/graph.ts
- ../sm-seo-plugin/assets/src/elements/parent.ts
- ../sm-seo-plugin/assets/src/elements/index.ts
- ../sm-seo-plugin/assets/src/elements/labels.ts
- ../sm-seo-plugin/assets/src/elements/styles.css
- ../sm-seo-plugin/assets/dist/seo.js
- ../sm-seo-plugin/assets/dist/seo.css
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.yaml
- ../sm-seo-plugin/README.md
- ../sm-seo-plugin/structured_data_test.go
autonomous: false
requirements: [SC-2, SC-3, D-43, D-44, D-45, D-46, D-03, D-86, D-55, D-56, D-57, D-40, D-41, D-42, D-21, D-94, D-10, D-70, D-85]
estimate:
tokens: 172000
raw_tokens: 172000
tasks: 4
confidence: low
must_haves:
truths:
- "The resolve answer of every ok page carries jsonld: one {\"@context\": \"https://schema.org\", \"@graph\": [...]} whose nodes link by @id: WebPage (or its subtype) + BreadcrumbList + references to WebSite and the publisher on every page; WebSite (+ SearchAction when golem15.seo.search_url is set) and the publisher on the home page; BlogPosting/Article/NewsArticle with author Person, publisher, image, datePublished, dateModified, wordCount and articleSection for article content; CollectionPage with mainEntity pointing to #itemlist for collections (the layer adds the ItemList node); ProfilePage + Person for profiles; Service with provider, areaServed and an optional Offer for registry offers and services; ContactPage + the publisher with address and opening hours; a VideoObject per YouTube video (D-43, D-94, D-96)"
- "The publisher is set in the global settings (Publisher tab: Organisation or Person, name, logo through seoimage, url, email, phone, sameAs; Local business tab behind a switch with type, address, geo, opening hours and price range); a person cannot be a local business (settings.local_person) and every C20 refusal answers 422 (D-45, D-03)"
- "The SEO tab gains Structured data (E8 seoschema: type select per kind, Service fields, custom JSON-LD with live parse feedback), Structured data preview (E9: the effective @graph of the saved page plus unsaved schema, required and recommended findings, the Rich Results Test link) and Primary category for content whose descriptor supplies Parents (D-44, D-46, D-86)"
- "Saving custom JSON-LD refuses invalid JSON, non-objects, more than 16 KB, nesting deeper than 8, a literal <script and a type outside the kind's list with the schema.* copy; a custom node whose @id matches a generated node merges into it with custom values first and the save answers the schema.merged notice (D-44, UI-SPEC C17)"
- "No e-commerce type (Product, Offer as a product) is offered or generated (out of scope)"
- "Breadcrumbs come from one source: the resolve answer's breadcrumbs [{name, url, path}] equals the BreadcrumbList items; the first item is the home crumb (settings breadcrumb_home per locale), a post's trail goes through its primary parent (seo_primary_parent, else the first Parent, recursively, at most 6 levels) and the current page is last (D-86)"
- "hreflang comes from one source: the resolve answer's alternates list each translated locale with a URL including the page itself plus x-default for the default locale; a locale without a translation is absent and a page with one locale has no alternates; html_lang, seo.og.locale and seo.og.locale_alternates come from the same data (D-55, D-56, D-57)"
- "Pagination follows the per-site form (Pagination tab: query ?page=N default, or path /<segment>/N with a segment per locale): ?page=1 and /<segment>/1 answer 301 to the unnumbered URL, the other form answers 301 to the configured one, a garbage page value answers not_found with pagination.invalid true; page 2 and later answer ok with index,follow, a self-referencing canonical with the page, and the title and description suffixes with %page% filled and %pages% left for the layer (D-40, D-41, D-42, D-21)"
- "GET /_seo/api/v1/site additionally answers pagination {form, param, segments} and home_crumb per locale, so the layer builds numbered links and checks drift (D-40, D-20c)"
- "Validation runs in three places: E9 in the admin against Google's required and recommended properties per type (FAQPage, CollectionPage, WebPage and Service get the noRules line), the crawler in 33-10 on the rendered page, and the Rich Results Test link per URL (D-46)"
- "MCP: seo_get_graph (GET /_seo/api/v1/graph?url=&schema=) returns the graph and findings; seo_set_url_seo and apply_batch accept primary_parent and schema; seo_update_settings writes every new settings key; TestEveryAdminOperationHasTool maps the seo_graph widget action (D-10)"
- "E9 with no findings shows the single allGood line; E8 with no value shows the kind's default type and note with the custom JSON-LD collapsed (UI-SPEC empty: E8, E9)"
- "E9 shows the checking line with aria-busy while building and queues at most one request (UI-SPEC loading: E9)"
- "An E9 action failure shows the 13px danger line with circle-alert and keeps the last graph; an element script failure shows the existing ExtensionFailure box (UI-SPEC error)"
- "A 422 on the settings form or on E8 shows field lines, the count banner and the tab error badge (UI-SPEC error: save refused)"
- "E8 and E9 follow the ML locale switcher; P2 rows are per locale (UI-SPEC partial: SEO in one locale only)"
- "P1 shows status.primary_gone when the stored primary category is no longer assigned, and the trail falls back to the first category (UI-SPEC partial: primary category gone)"
- "E9's JSON pre has max-height 360px and scrolls in both axes (UI-SPEC overflow: E9)"
- {statement: "E8 value round-trip: Service fields come back as the same JSON string; an invalid price shows priceInvalid; a JSON parse error names line and column; more than 16 KB shows jsonTooLarge (UI-SPEC E8 backstop)", verification: backstop}
- "Before github.com/chromedp/chromedp (33-10, the rendered comparison) or github.com/gen2brain/webp (33-11, WebP variants) enters any go.mod, Jakub verifies both modules at one blocking checkpoint (Task 4), and the approved version of each, or its rejection, is recorded at the top of 33-06-SUMMARY.md (RESEARCH Package Legitimacy Audit: both [ASSUMED]; CONTEXT 2026-10-28 rendered comparison and image formats; P33-56)"
prohibitions:
- requirement_id: "D-44"
category: safety
status: resolved
verification: test
statement: "Custom JSON-LD can never close the script element or inject markup: a literal <script is refused on save and the JSON is re-serialised by the server, never echoed as typed"
artifacts:
- path: "../sm-seo-plugin/classes/schema/graph.go"
provides: "@graph builder per kind"
contains: "@graph"
- path: "../sm-seo-plugin/classes/schema/validate.go"
provides: "required/recommended rules per type"
contains: "func "
- path: "../sm-seo-plugin/classes/pagination.go"
provides: "page form normalisation and suffixes"
contains: "%pages%"
- path: "../sm-seo-plugin/structured_data_test.go"
provides: "TestGraphTracer, TestGraphPerKind, TestCustomJSONLD, TestAlternates, TestBreadcrumbs, TestPagination, TestPublisherSettings, TestGraphTool"
contains: "func TestGraphTracer("
key_links:
- from: "../sm-seo-plugin/classes/resolver.go"
to: "../sm-seo-plugin/classes/schema/graph.go"
via: "resolveAt builds jsonld, breadcrumbs and alternates from the described record and settings"
pattern: "schema\\.Build"
- from: "../sm-seo-plugin/assets/src/elements/graph.ts"
to: "../sm-seo-plugin/controllers/api/graph.go"
via: "the seo_graph widget action (op graph) and the MCP tool share classes/schema Build + Validate"
pattern: "seo_graph"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan puts structured data, breadcrumbs, hreflang and pagination into the one resolve answer per URL, and gives editors the publisher settings and the schema fields in the SEO tab.
<objective>
Extend ../sm-seo-plugin (main, not pushed):
- the JSON-LD @graph builder per content kind with validators (D-43, D-46) and the publisher and local-business settings (D-45, D-03);
- breadcrumbs with the primary category (D-86), alternates/x-default/og:locale (D-55..57), pagination forms and suffixes (D-40..42) in resolve and /site;
- SEO tab additions E8 (schema), E9 (graph preview), primary category, P1/P2 rows (D-44, D-46);
- MCP `seo_get_graph` and the new keys on existing tools (D-10);
- the blocking legitimacy checkpoint for the phase's two new Go modules, chromedp and gen2brain/webp, before 33-10 and 33-11 add them (P33-56).
Purpose: SC-2 (one JSON-LD @graph, hreflang and breadcrumbs from the CMS) on the backend side; the layer renders it (33-07 to 33-09).
Output: commits in ../sm-seo-plugin, with the rebuilt element bundle.
**Planner decisions recorded here (resolve answer additions are `costly`: every app's layer reads them):**
- **P33-21 (resolve answer keys, additive):** top level `html_lang` (locale code, region kept when the locale has one), `alternates: [{hreflang, url}]` (absolute), `jsonld` (object or null), `breadcrumbs: [{name, url, path}]`, `pagination: {form, param, segment, page, invalid}` (present only when the request carries a page); inside `seo.og`: `locale`, `locale_alternates` (from config `golem15.seo.og_locales`, default {pl: pl_PL, en: en_US, de: de_DE}, else `<code>_<CODE>`). No existing key changes.
- **P33-22 (@id scheme):** `<site>/#website`, `<site>/#organization` or `<site>/#person` (publisher), `<url>#webpage`, `<url>#breadcrumb`, `<url>#article`, `<url>#itemlist` (layer node), `<url>#profile`, `<url>#service`, `<url>#video-<n>`.
- **P33-23 (primary parent field):** extension dropdowns accept only listed options (32-01), so the per-record choice is a plugin field type `seoparent` (element `golem15-seo-parent`, a 44px select styled as the built-in dropdown with emptyOption field.primary_category_default) whose Load answers {selected, options} from the descriptor's Parents; it shows only for types whose records have Parents. The value is stored in the entry's data (key primary_parent) on every locale row, as the social image is (Phase 32 Q-5); seo_schema likewise (key schema).
- **P33-24 (%pages%):** the resolver cannot know a listing's page count; it fills %page% and leaves the literal %pages% in seo.title and seo.description on page ≥ 2; the layer's usePagination replaces it (33-08).
- **P33-25 (search):** config `golem15.seo.search_url` (for example `/szukaj?q={search_term_string}`), empty means no SearchAction.
- **P33-56 (one legitimacy checkpoint for both Go modules, 2026-10-28 revision):** Jakub chose github.com/chromedp/chromedp (CONTEXT, rendered comparison) and github.com/gen2brain/webp (CONTEXT, image formats); RESEARCH's audit marks both [ASSUMED] because its legitimacy seam cannot check Go modules. Both are verified together here, at the end of the plan 33-10 depends on, instead of inside 33-10 and 33-11, so that neither of those plans exceeds its task budget and the two install tasks run without stopping. The checkpoint installs nothing; each consumer task carries a precondition on this answer, and each decision note (written in 33-10 and 33-11 before their go.mod changes) cites it.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-01-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-01: pact.ContentDescriptor{Kind, Tools}; ContentRecord{PublishedAt, Parents []ContentRef{Type, ID}, Authors []ContentPerson{Name, Ref}, TopHeading}; ContentLocale{Images []ContentImage{URL, Alt, Decorative, Width, Height}, Videos []ContentVideo{Provider, ID}, Headings}.
From 33-05: Result.Outbound; /site answer (P33-17); contract.Listing; registry kinds (page, offer, service, landing) on seo-page records.
Phase 32: classes/resolver.go resolveAt (record, rules, templates, canonical keeps page via D-07 list), classes/normalize.go (one-hop normalisation), classes/settings_write.go four-place pattern, classes/field_types.go (seotext, seorobots, seoimage E7 Decode/Load), extensions.go seoTabYAML (anchors seo_robots, seo_og_image), extension/_seo_status.htm (P1), _seo_effective.htm (P2), assets/src/elements/* (E1–E7 framework-free custom elements, labels.ts by ui-locale, styles.css light DOM, summer-action through WidgetField), tests/elements/*.test.ts (vitest + happy-dom), scripts/check-dist.sh.
Google (cited in 33-RESEARCH.md): Article has no required properties, recommended author, dateModified, datePublished, headline, image; BreadcrumbList needs at least two ListItem with position, name, item; VideoObject requires name, thumbnailUrl, uploadDate; LocalBusiness requires name and address; FAQ rich results are gone (no rules for FAQPage).
Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local business), E8/E9 element copy, server copy for E8, P8.
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; element changes rebuild `assets/dist` (`npm --prefix ../sm-seo-plugin run build`) in the same group; lang keys in both files.
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)` | TestGraphTracer passes |
| 2 | A `feat: publisher, local business and home crumb settings`; B `feat: pagination forms and suffixes in resolve and site` | settings and pagination tests pass |
| 3 | A `feat: graph validation and custom JSON-LD`; B `feat: structured data, graph preview and primary category in the SEO tab`; C `feat: graph MCP tool and new SEO keys`; D `docs: structured data, breadcrumbs, hreflang and pagination in README` | element and Go suites pass |
| 4 | none (checkpoint answers recorded at the top of the SUMMARY) | both answers recorded; plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "resolving a post's URL returns one @graph, its breadcrumbs and its hreflang alternates" — one path only</name>
<files>../sm-seo-plugin/classes/schema/graph.go, ../sm-seo-plugin/classes/schema/nodes.go, ../sm-seo-plugin/classes/breadcrumbs.go, ../sm-seo-plugin/classes/alternates.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/updates/202610280004_add_golem15_seo_publisher_pagination.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/structured_data_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/resolver.go (Result, SEO, resolveAt, contentLocale) and classes/templates.go
- ../sm-seo-plugin/fixture_test.go (fake content plugin; extend its descriptor with the 33-01 fields)
- summercms.go/modules/pact/content.go (33-01 fields)
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-43 table, D-55..57, D-86)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 4, Pitfall 3, Pitfall 11)
</read_first>
<action>
Per D-43, D-55 to D-57, D-86, P33-21 and P33-22: migration 202610280004 adds every settings column of this plan (publisher_*, local_* incl. local_hours jsonb, breadcrumb_home, page_form text default 'query', page_segment, page_title_suffix, page_description_suffix; translatable ones through the translation writer like site_name); Task 1 uses publisher_type and publisher_name only. `classes/schema`: `Build(ctx, in Input) (map[string]any, error)` composing nodes per P33-22 from the described record (Kind), settings and the page URL; nodes.go holds one function per node type. `classes/breadcrumbs.go`: home crumb, primary parent chain (entry data primary_parent, else first Parent; Describe each parent recursively, 6 levels, skip unpublished), current page; both the BreadcrumbList node and the answer list. `classes/alternates.go`: translated locales with a URL (≥2) plus x-default for the default locale, html_lang, og locale values. resolveAt sets the new keys for ok answers only. Config keys `og_locales` and `search_url` commented in config/config.yaml. `TestGraphTracer` (Docker): a fake article record with PublishedAt, two Parents, an author, two locales and a video, publisher Organisation "Acme" → resolve answers jsonld with WebPage, BlogPosting (headline, author name, datePublished, dateModified, image, wordCount, articleSection = first parent's title), BreadcrumbList (home, parent, post), VideoObject, WebSite and Organization nodes linked by the P33-22 @ids; breadcrumbs equals the BreadcrumbList items; alternates lists pl, en and x-default; a single-locale record has none. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestGraphTracer' -count=1 -v 2>&1 | tail -20</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestGraphTracer" line</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func Build(" ../sm-seo-plugin/classes/schema/graph.go` prints one line
- `grep -n 'json:"jsonld\|json:"alternates\|json:"breadcrumbs\|json:"html_lang' ../sm-seo-plugin/classes/resolver.go` prints four lines
- TestResolverPrecedence and the Phase 32 resolve tests still pass (no existing key changed)
</acceptance_criteria>
<done>One resolve call returns the page's @graph, breadcrumbs and hreflang data.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Publisher, local business, home crumb and pagination settings; pagination forms, suffixes and normalisation in resolve and /site</name>
<files>../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/pagination.go, ../sm-seo-plugin/classes/normalize.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/site.go, ../sm-seo-plugin/classes/schema/nodes.go, ../sm-seo-plugin/controllers/settings/_publisher_preview.htm, ../sm-seo-plugin/controllers/settings/config_form.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/structured_data_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/settings_write.go and models/settings/fields.yaml (tabs and the four-place pattern)
- ../sm-seo-plugin/classes/normalize.go (one-hop normalisation) and classes/urls.go (D-07 parameter list keeps page)
- ../sm-seo-plugin/controllers/settings/_placeholders.htm (settings partial pattern)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C20 tabs and copy, P8)
</read_first>
<behavior>
- TestPublisherSettings: Publisher tab saves type, name, logo, url, email, phone, sameAs; sameAs line "x.pl" → 422 settings.same_as_invalid line 1; local_enabled with publisher Person → 422 settings.local_person; local without street → settings.local_address_required; "9:00" → settings.time_invalid; closes before opens → settings.hours_order; country "POL" → settings.country_invalid; latitude 91 → settings.coordinate_invalid; P8 shows the generated publisher node and its findings (missing logo → warning line), or "No problems"
- The publisher node becomes the local_type (LocalBusiness subtype) with PostalAddress, GeoCoordinates, openingHoursSpecification and priceRange when local_enabled; ContactPage pages reference it
- TestPagination (query form): /blog?page=1 → 301 /blog; /blog?page=3 → ok, canonical /blog?page=3, robots index,follow, title "<title> – strona 3 z %pages% | Site" in pl, description suffix appended; /blog?page=abc, ?page=0, ?page=-1 → not_found with pagination.invalid; /blog/strona/3 → 301 /blog?page=3
- TestPagination (path form, segment pl strona): /blog?page=3 → 301 /blog/strona/3; /blog/strona/1 → 301 /blog; /blog/strona/x → not_found invalid; /en/blog/page/2 uses the en segment
- Every 301 reaches its final address in one hop together with the Phase 32 normalisation and redirects
- /site answers pagination {form, param: page, segments} and home_crumb {pl: Strona główna, en: Home} by default; breadcrumb_home changes them
- Unknown placeholders in a suffix refuse with settings.template_unknown; a segment outside [a-z0-9-] refuses with settings.page_segment_invalid
</behavior>
<action>
Per D-03, D-45, D-40 to D-42, D-21 and P33-24: the four-place settings pattern for every new column with C20 validation; fields.yaml tabs General (breadcrumb_home after twitter_site), Pagination, Publisher (publisher_logo uses the E7 seoimage type; P8 partial `_publisher_preview.htm` on update), Local business (local_enabled switch and the trigger-hidden fields, local_hours repeater with titleFrom day), in the C20 tab order. nodes.go builds the publisher node from these settings. `classes/pagination.go`: parse the page from the query or the configured path segment (per locale), the 301 rules (folded into the one-hop decision of normalize.go/resolver so a normalisation and a page redirect are one hop), the invalid answer, and the title/description suffixes (%page% filled, %pages% kept per P33-24); robots for page ≥ 2 stay index,follow with the canonical keeping the page (D-21). site.go adds pagination and home_crumb. Tests first. Commit groups A and B.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestPublisherSettings|TestPagination|TestSiteEndpoint|TestNormalize' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestPublisherSettings or TestPagination missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "page_form\|page_segment\|publisher_type\|local_enabled\|breadcrumb_home" ../sm-seo-plugin/classes/settings_write.go` prints at least five lines
- `grep -n "%pages%" ../sm-seo-plugin/classes/pagination.go` prints at least one line
- `grep -n '"pagination"\|"home_crumb"' ../sm-seo-plugin/classes/site.go` prints two lines
</acceptance_criteria>
<done>The publisher and pagination are settings, and every paginated URL resolves in its configured form with the D-41 rules.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Validators and custom JSON-LD, SEO tab E8/E9/primary category, P1/P2 additions, seo_get_graph and the new keys</name>
<files>../sm-seo-plugin/classes/schema/validate.go, ../sm-seo-plugin/classes/schema/custom.go, ../sm-seo-plugin/classes/schema/graph.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/classes/entries.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/classes/manage.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/extension/_seo_effective.htm, ../sm-seo-plugin/field_types.go, ../sm-seo-plugin/controllers/api/graph.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/assets/src/elements/schema.ts, ../sm-seo-plugin/assets/src/elements/graph.ts, ../sm-seo-plugin/assets/src/elements/parent.ts, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/structured_data_test.go</files>
<read_first>
- ../sm-seo-plugin/extensions.go (seoTabYAML, reserved anchors, Save/Load, widget actions such as seo_snippet)
- ../sm-seo-plugin/classes/field_types.go and field_types.go (seoimage Decode/Load and registration)
- ../sm-seo-plugin/assets/src/elements/{serp,robots,image,actions,labels,index}.ts and styles.css (E1–E7 patterns, summer-action, summer:ml-locale)
- ../sm-seo-plugin/tests/elements/host.ts and serp.test.ts (element test harness)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C16, C17, C18, E8/E9 copy, Shared rules, Color, Typography)
</read_first>
<behavior>
- TestCustomJSONLD: each refusal of C17 answers 422 with its schema.* key on seo_schema; a custom node with @id <url>#article and headline "X" replaces the generated headline and the save answers schema.merged naming the property; a custom node with a new @id is appended to the graph; the stored value is re-serialised JSON
- TestGraphPerKind: collection → CollectionPage with mainEntity #itemlist; profile → ProfilePage + Person; registry offer → Service with provider ref and areaServed/price/currency from the schema fields; registry page with schema ContactPage → ContactPage referencing the publisher; home → WebSite with SearchAction when search_url is set
- Validate: BlogPosting missing image → recommended finding image; VideoObject missing uploadDate → required finding; LocalBusiness without address → required; FAQPage, CollectionPage, WebPage, Service → rules false
- TestGraphTool: GET /_seo/api/v1/graph?url=<post>&schema=<json> answers {locale, url, graph, nodes[{id, type, findings, rules}]}; without golem15.seo.manage 403; the seo_graph widget action answers the same data; MCP lists get_graph; TestEveryAdminOperationHasTool passes
- seo_set_url_seo with primary_parent and schema writes the entry data on every locale row; apply_batch accepts both keys with dry_run
- P1 shows status.primary_gone for a stored primary parent the record no longer has; P2 shows Schema type and Breadcrumb trail rows
- Element smoke (vitest): E8 renders the article type options and the Service grid; E9 renders nodes and findings from a stubbed action answer; the parent select lists the options from its value
</behavior>
<action>
Per D-44, D-46, D-86, UI-SPEC C16–C18 and P33-23: `classes/schema/validate.go` with per-type required/recommended rules; `custom.go` decoding and merging per C17 server rules; field type `seoschema` (element golem15-seo-schema, option kind from the descriptor's Kind; Decode validates and normalises the JSON string) and `seoparent` (element golem15-seo-parent per P33-23); extensions.go inserts seo_primary_parent (only when the type's records have Parents), seo_schema and seo_graph (type widget, action seo_graph, fill [seo_schema], context update) at the C16 positions, writes primary_parent and schema into the entry data, and answers the merge notice; `controllers/api/graph.go` + route `GET /_seo/api/v1/graph` (management group) + MCP tool get_graph + AdminOperations rows (widget_action:seo_graph#graph → get_graph; the extension save keys stay on set_url_seo); controllers/api/seo.go and classes/manage.go accept the new keys. Elements per C17/C18 (light DOM, `--c-*` colours, labels by ui-locale with every E8/E9 key, 300 ms parse debounce, 800 ms graph debounce, one queued request, Rich Results link `https://search.google.com/test/rich-results?url=<encoded>` with rel noopener), parent.ts select; rebuild dist. P1 primary_gone and P2 rows. README sections for structured data, breadcrumbs, hreflang and pagination. Tests first (Go and a vitest smoke per element in tests/elements). Commit groups A to D.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCustomJSONLD|TestGraphPerKind|TestGraphTool|TestGraphTracer|TestEveryAdminOperationHasTool|TestSEOTab' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestCustomJSONLD, TestGraphPerKind or TestGraphTool missing from the "--- PASS" lines</fails_when>
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -15 && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit, a failed count above 0 in the vitest summary, or the dist check reporting a difference</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"seoschema"\|"seoparent"' ../sm-seo-plugin/classes/field_types.go ../sm-seo-plugin/field_types.go` prints at least two lines
- `grep -n "golem15-seo-schema\|golem15-seo-graph\|golem15-seo-parent" ../sm-seo-plugin/assets/src/elements/index.ts` prints three lines
- `grep -n '"get_graph"' ../sm-seo-plugin/mcp.go` prints one line
- `grep -rn "Product" ../sm-seo-plugin/classes/schema/` prints nothing
</acceptance_criteria>
<done>Editors choose schema types and custom JSON-LD with live checks, see the effective graph with Google's rules, and MCP can do the same.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 4: Package legitimacy of github.com/chromedp/chromedp and github.com/gen2brain/webp before either enters a go.mod</name>
<what-built>Nothing is installed. Per P33-56, this checkpoint verifies the two Go modules Jakub chose on 2026-10-28 (CONTEXT: rendered comparison through optional chromedp, used by 33-10; WebP variants through gen2brain/webp, used by 33-11). 33-RESEARCH.md's Package Legitimacy Audit marks both [ASSUMED]: the npm/pypi/crates seam cannot check Go modules, so only proxy.golang.org existence was confirmed (chromedp v0.21.0 of 2026-10-11, 82 versions since v0.1.0 in 2018-07; gen2brain/webp v0.6.4 of 2026-07-01, 31 versions since v0.1.0 in 2024-03).</what-built>
<how-to-verify>
1. chromedp: open https://pkg.go.dev/github.com/chromedp/chromedp and https://github.com/chromedp/chromedp: the module path matches, the repository belongs to the chromedp organisation (which also publishes github.com/chromedp/cdproto), the licence is permissive (MIT), the latest tag is v0.21.0 or newer, and its README says it drives a Chrome or Chromium binary through the DevTools protocol without other runtime dependencies.
2. gen2brain/webp: open https://pkg.go.dev/github.com/gen2brain/webp and https://github.com/gen2brain/webp: the module path matches, the repository is the author's (gen2brain also maintains gen2brain/avif and go-fitz), the licence is permissive, the latest tag is v0.6.4 or newer, and the README describes libwebp through purego when the system library is present and a WASM build through wazero otherwise, with no cgo.
3. Check proxy.golang.org lists the versions to pin: https://proxy.golang.org/github.com/chromedp/chromedp/@v/list and https://proxy.golang.org/github.com/gen2brain/webp/@v/list
4. Answer for each module "approved vX.Y.Z" (the version to pin) or "rejected: reason".
</how-to-verify>
<resume-signal>Reply "chromedp: approved vX.Y.Z | rejected: reason; webp: approved vX.Y.Z | rejected: reason". A rejected chromedp means 33-10 Task 4 stops, its crawls keep reporting the comparison as skipped, and the SUMMARY records the decision for revisiting; a rejected webp means 33-11 Tasks 3 and 4 stop and the SUMMARY records WebP as not delivered.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin/MCP → custom JSON-LD and settings | untrusted structured input stored and later embedded in pages |
| resolve answer → layer | JSON-LD rendered into a script element |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-24 | Tampering (XSS) | custom JSON-LD | high | mitigate | parse, objects only, 16 KB and depth 8 caps, refuse a literal <script, re-serialise with encoding/json (which escapes <, > and &); the layer additionally relies on unhead's JSON escaping (33-07); TestCustomJSONLD |
| T-33-25 | Tampering | publisher sameAs, URLs | medium | mitigate | https-only absolute URLs validated on save; emitted only inside JSON |
| T-33-26 | Spoofing | breadcrumbs and alternates | low | mitigate | built from signpost descriptors and settings only, never from the request |
| T-33-27 | Elevation of privilege | graph tool, settings, SEO tab keys | high | mitigate | golem15.seo.manage on every route and widget action (existing manage wrapper), 403 in TestGraphTool |
| T-33-28 | Denial of service | graph building per resolve | low | mitigate | descriptor reads per resolve are already one call; parent chain capped at 6 |
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | this plan installs no package (the element bundle rebuilds from the committed lockfile); Task 4 is the blocking human legitimacy checkpoint for the phase's two [ASSUMED] Go modules, github.com/chromedp/chromedp and github.com/gen2brain/webp, whose approved versions 33-10 and 33-11 pin exactly, each after its decision note |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
- Docker suite: structured_data_test.go tests and the Phase 32/33-05 suites.
- `npm --prefix ../sm-seo-plugin run test` and `bash ../sm-seo-plugin/scripts/check-dist.sh` pass.
</verification>
<success_criteria>
- The resolve answer carries one @graph, breadcrumbs, alternates and pagination data.
- Publisher, local business, pagination and home crumb are settings; the SEO tab has E8, E9 and the primary category.
- seo_get_graph and the new keys work through MCP.
- Both new Go modules are verified (or rejected) before any go.mod changes, and the answers are on record.
</success_criteria>
## Artifacts this phase produces
- Package `classes/schema`: `Build`, `Input`, node builders, `Validate`, custom JSON-LD decode/merge.
- Go symbols: breadcrumbs, alternates and pagination builders; field types `seoschema`, `seoparent`; API handler `Graph`.
- Resolve keys: `html_lang`, `alternates`, `jsonld`, `breadcrumbs`, `pagination`, `seo.og.locale`, `seo.og.locale_alternates`; /site keys `pagination`, `home_crumb`.
- Settings columns: `breadcrumb_home`, `page_form`, `page_segment`, `page_title_suffix`, `page_description_suffix`, `publisher_type`, `publisher_name`, `publisher_logo`, `publisher_url`, `publisher_email`, `publisher_phone`, `publisher_same_as`, `local_enabled`, `local_type`, `local_price_range`, `local_street`, `local_postal_code`, `local_city`, `local_region`, `local_country`, `local_latitude`, `local_longitude`, `local_hours`; migration 202610280004.
- Entry data keys: `primary_parent`, `schema`.
- Elements: `golem15-seo-schema` (E8), `golem15-seo-graph` (E9), `golem15-seo-parent`; partial `publisher_preview` (P8).
- Route: `GET /_seo/api/v1/graph`; MCP tool `seo_get_graph`.
- Config keys: `golem15.seo.og_locales`, `golem15.seo.search_url`.
- Tests: `TestGraphTracer`, `TestGraphPerKind`, `TestCustomJSONLD`, `TestPublisherSettings`, `TestPagination`, `TestGraphTool` (plus `TestAlternates` and `TestBreadcrumbs` subtests in the tracer file).
- Checkpoint record: approved versions (or rejections) of `github.com/chromedp/chromedp` and `github.com/gen2brain/webp` at the top of 33-06-SUMMARY.md.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md` when done; put the Task 4 answers (the approved version of chromedp and of gen2brain/webp, or the rejection) at its top for 33-10 and 33-11, and copy the final resolve-answer JSON of TestGraphTracer into it so the layer fixtures (33-07 to 33-09) can be compared.
</output>

View File

@@ -0,0 +1,332 @@
---
phase: 33-seo-output-audit-and-content
plan: 07
type: execute
wave: 1
depends_on: []
files_modified:
- ../vue-seo-layer/.gitignore
- ../vue-seo-layer/.nvmrc
- ../vue-seo-layer/package.json
- ../vue-seo-layer/pnpm-lock.yaml
- ../vue-seo-layer/pnpm-workspace.yaml
- ../vue-seo-layer/tsconfig.json
- ../vue-seo-layer/README.md
- ../vue-seo-layer/nuxt.config.ts
- ../vue-seo-layer/lib/seo-module.ts
- ../vue-seo-layer/app/app.config.ts
- ../vue-seo-layer/app/plugins/seo-head.ts
- ../vue-seo-layer/app/composables/useCmsSeo.ts
- ../vue-seo-layer/app/composables/useSchemaNode.ts
- ../vue-seo-layer/app/composables/useSeoLabels.ts
- ../vue-seo-layer/app/utils/graph.ts
- ../vue-seo-layer/app/utils/pagePath.ts
- ../vue-seo-layer/app/utils/labels.ts
- ../vue-seo-layer/app/utils/types.ts
- ../vue-seo-layer/app/assets/css/seo-tokens.css
- ../vue-seo-layer/app/assets/css/seo-base.css
- ../vue-seo-layer/server/middleware/seo.ts
- ../vue-seo-layer/server/middleware/seo-root.ts
- ../vue-seo-layer/server/lib/resolve.ts
- ../vue-seo-layer/server/lib/lru.ts
- ../vue-seo-layer/server/lib/site.ts
- ../vue-seo-layer/server/lib/siteCachePurge.ts
- ../vue-seo-layer/server/api/__seo__/resolve.get.ts
- ../vue-seo-layer/server/api/__site__/cache/purge.post.ts
- ../vue-seo-layer/server/plugins/site-cache-purge.ts
- ../vue-seo-layer/server/plugins/seo-notfound.ts
- ../vue-seo-layer/.playground/nuxt.config.ts
- ../vue-seo-layer/.playground/app/app.vue
- ../vue-seo-layer/.playground/app/pages/index.vue
- ../vue-seo-layer/.playground/app/pages/[...slug].vue
- ../vue-seo-layer/testing/smoke-ssr.mjs
- ../vue-seo-layer/testing/fixture-api.mjs
- ../vue-seo-layer/testing/nuxt-server.mjs
- ../vue-seo-layer/testing/purge-check.mjs
- ../vue-seo-layer/testing/purge-token-check.mjs
- ../vue-seo-layer/testing/expect-seo.txt
- ../vue-seo-layer/testing/fixtures/seo/resolve.json
- ../vue-seo-layer/testing/fixtures/seo/site.json
- ../vue-seo-layer/tests/graph.test.ts
- ../vue-seo-layer/tests/pagePath.test.ts
- ../vue-seo-layer/tests/lru.test.ts
- ../vue-seo-layer/tests/labels.test.ts
- ../vue-seo-layer/tests/sitePurge.test.ts
autonomous: false
requirements: [SC-2, D-97, D-20, D-20a, D-20b, D-20c, D-08, D-05, D-103, D-43, D-55, D-71, D-22, D-25]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 4
confidence: low
must_haves:
truths:
- "A new Nuxt layer repository (name confirmed at Task 1, proposed vue-seo-layer) exists locally with nuxt 4.4.8 and no runtime package beyond Nuxt; it does not use @nuxtjs/seo, nuxt-schema-org, @nuxtjs/robots or @nuxtjs/sitemap (D-97, RESEARCH Standard Stack)"
- "A Nitro middleware resolves every page request (skipping /_nuxt, /api, /__nuxt_error and asset files) through GET /_seo/api/v1/resolve with the visitor's X-Forwarded-For, enforces action redirect with the answer's status and Location and action gone with 410, and sets X-Robots-Tag from seo.x_robots_tag; not_found is left to Nuxt's routing except a pagination.invalid answer, which is a 404 (D-20a, D-103, D-08, D-71, D-42)"
- "Normalisation and strict-URL answers (Phase 32) arrive as redirect decisions, so the layer enforces D-20b and D-20c on frontend routes in one hop without re-implementing any rule (D-20b, D-20c)"
- "Redirects and 410 apply before Nitro's route cache, so swr-cached pages honour them; the Location always comes from the resolve answer, never from the request (D-20a)"
- "Every server-rendered page gets its head from the CMS answer only: title, description, robots, canonical, og:* (including og:locale and og:locale:alternate), twitter:*, verification meta, <html lang>, hreflang links and exactly one <script type=\"application/ld+json\"> holding one @graph (CMS nodes plus nodes the layers add through useSchemaNode, merged by @id); client-side navigation fetches the same answer through /api/__seo__/resolve (D-43, D-55, D-97)"
- "Resolve answers are cached in an LRU (path and query as key, TTL seo.resolveCacheTtl default 60 s, 1000 entries); the shared site-cache purge route POST /api/__site__/cache/purge (moved here from the journal, menu and FAQ layers unchanged in contract) also clears the resolve and site caches (RESEARCH Pitfall 6, 33-CONTEXT discretion: purge with D-97)"
- "The layer serves the site-root files from the CMS: /robots.txt, /sitemap.xml, /sitemap-<name>.xml, /llms.txt, each verification file and the IndexNow key file listed in the site answer's root_files, proxied to the Go backend with their status, content type and X-Robots-Tag; any other one-segment path passes through (D-05, D-103, D-22, D-25, D-31)"
- "A page that renders as 404 reports itself once to POST /_seo/api/v1/notfound with the bearer token runtimeConfig.seo.notfoundToken, its URL and the referer, without delaying the response (D-20)"
- "With the CMS unreachable or no SEO plugin mounted, pages render normally without CMS head tags (fail open); seo.cms false turns every CMS call off for sites without the SEO plugin"
- "useSeoLabels() returns the L8 dictionary of the page's content locale (resolve locale, then html_lang's primary subtag, then en), overridable in app.config.ts under seo.labels; the layer has no @nuxtjs/i18n dependency (UI-SPEC L8)"
- "seo-tokens.css and seo-base.css load before the app's CSS and style only .seo-* classes (UI-SPEC L8, Spacing, Color)"
- "Public layer components render on the server only: the data is fetched before render, so there is no client loading state (UI-SPEC loading: public layer components)"
- "The module option seo.links.newTabHint defaults to true and reaches runtimeConfig.public.seo.links.newTabHint, so the visually hidden new-tab hint is on by default while the CMS's links_new_tab is on, and an app can switch it off (CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8; applied to body links in 33-09)"
- {statement: "Two concurrent requests for the same uncached path make one resolve call (in-flight de-duplication), and a purge during a request never serves a stale answer afterwards", verification: backstop}
prohibitions:
- requirement_id: "D-20a"
category: safety
status: resolved
verification: test
statement: "The middleware never builds a redirect Location from request input (Host, path or query); it only forwards the resolver's absolute location"
- requirement_id: "D-97"
category: values
status: resolved
verification: test
statement: "The layer never emits a second JSON-LD script or a second canonical link"
artifacts:
- path: "../vue-seo-layer/server/middleware/seo.ts"
provides: "resolve, redirect/410, X-Robots-Tag, event.context.seo"
contains: "sendRedirect"
- path: "../vue-seo-layer/app/composables/useCmsSeo.ts"
provides: "head rendering from the resolve answer"
contains: "application/ld+json"
- path: "../vue-seo-layer/server/middleware/seo-root.ts"
provides: "root file proxies"
contains: "robots.txt"
- path: "../vue-seo-layer/testing/expect-seo.txt"
provides: "SSR smoke expectations"
contains: "count="
key_links:
- from: "../vue-seo-layer/server/middleware/seo.ts"
to: "../vue-seo-layer/server/lib/resolve.ts"
via: "resolveCached(event, path) with LRU and X-Forwarded-For"
pattern: "resolveCached"
- from: "../vue-seo-layer/app/plugins/seo-head.ts"
to: "../vue-seo-layer/app/composables/useCmsSeo.ts"
via: "the app plugin applies useCmsSeo on every route from event.context.seo (SSR) or /api/__seo__/resolve (client)"
pattern: "useCmsSeo"
- from: "../vue-seo-layer/server/api/__site__/cache/purge.post.ts"
to: "../vue-seo-layer/server/lib/resolve.ts"
via: "purge clears nitro:routes and the resolve and site LRUs"
pattern: "clear"
---
<!-- planner-discipline-allow: @nuxtjs/seo -->
<!-- planner-discipline-allow: nuxt-schema-org -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan creates the shared Nuxt SEO layer (D-97) and its runtime: every page's head comes from the CMS resolve answer, redirects and 410 are enforced before the page cache, root files are served from the CMS, and 404s are reported.
<objective>
Create the layer repository locally (the remote is created with `tea` in 33-14, behind the release checkpoint) and build its runtime against a fixture API:
- Nitro middleware for the redirect decision, X-Robots-Tag and the resolve context (D-20a, D-103, D-08);
- head rendering with one JSON-LD script and hreflang from the CMS (D-43, D-55);
- the resolve LRU and the shared site-cache purge route moved here (Pitfall 6);
- root-file proxies (robots.txt, sitemaps, llms.txt, verification files, IndexNow key; D-05, D-103);
- the 404 report call (D-20); labels and tokens (UI-SPEC L8).
Purpose: SC-2 (indexable pages render server-side with head tags and one JSON-LD @graph from the CMS); the build guard and components are 33-08.
Output: commits in ../vue-seo-layer (a new local repository, branch main, not pushed).
**Planner decisions recorded here:**
- **P33-26 (contract the fixtures follow):** the resolve answer is Phase 32's (action, status, location, url, locale, staging, seo{title, description, canonical, robots, x_robots_tag, og{title, description, image, url, locale, locale_alternates}, twitter{card, site}, verification[{name, content}]}) plus 33-05's `outbound` and 33-06's `html_lang`, `alternates[{hreflang, url}]`, `jsonld`, `breadcrumbs[{name, url, path}]`, `pagination{form, param, segment, page, invalid}` and 33-11's `autolinks[{phrase, url}]`, `autolink_max`; the site answer is 33-05's P33-17 plus 33-06's `pagination{form, param, segments}` and `home_crumb`. Absent keys are treated as empty. The fixture files hold exactly these shapes; 33-06-SUMMARY.md carries a real answer to compare in 33-12.
- **P33-27 (runtime config):** server-only `runtimeConfig.seo.apiBase` (NUXT_SEO_API_BASE, default http://127.0.0.1:8096/_seo/api/v1), `backendBase` (NUXT_SEO_BACKEND_BASE, default http://127.0.0.1:8096), `notfoundToken` (NUXT_SEO_NOTFOUND_TOKEN), `routeReportToken` (NUXT_SEO_ROUTE_REPORT_TOKEN, falls back to notfoundToken), `resolveCacheTtl` (seconds, default 60); `cachePurge.token`, `cachePurge.allowProxied` and the deprecated `siteCache` keys move here from the journal layer's config unchanged; module options (configKey `seo`): `cms` (default true), `breadcrumbs` (true), `links.newTabHint` (true, per CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8; copied to `runtimeConfig.public.seo.links.newTabHint` for 33-09's body pipeline), `rootFiles` (true), `resolveFiles` (['pdf']), plus the 33-08 options.
- **P33-28 (one head owner):** a single app plugin applies the head on every route; pages and other layers never call useSeoMeta for CMS values. useSchemaNode(node) registers a request-scoped node merged into the one graph by @id.
- **P33-29 (smoke harness):** testing/smoke-ssr.mjs is copied from vue-journal-layer and gains `count=TEXT:N` (exactly N occurrences in the body; the count is the number after the last colon) and `header=Name:Value` (response header equals) assertions, and lets `contains=`, `notcontains=`, `count=` and `header=` repeat on one line (every occurrence applies; today a repeated key overwrites the earlier one); the fixture API serves /_seo/api/v1/resolve answers keyed by path from testing/fixtures/seo/resolve.json, /_seo/api/v1/site, records /_seo/api/v1/notfound calls and resolve hit counts, and serves the root files.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@CLAUDE.md
<interfaces>
Templates to copy: ../vue-journal-layer/{.gitignore, .nvmrc, package.json (scripts dev, build:playground, test, dev:prepare), pnpm-workspace.yaml, tsconfig.json, nuxt.config.ts (currentDir pattern, runtimeConfig cachePurge/siteCache, routeRules cache false), lib/journal-module.ts (defineNuxtModule, configKey, defaults, runtimeConfig.public copy), server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, server/plugins/journal-client-address.ts (X-Forwarded-For forwarding), testing/{smoke-ssr.mjs, fixture-api.mjs, nuxt-server.mjs, purge-check.mjs, purge-token-check.mjs}, tests/sitePurge.test.ts, .playground/}.
Nuxt 4.4.8 / Nitro 2.13.4 / h3 1.15.11 / unhead 2.1.17 (pinned through nuxt): useHead, useSeoMeta, defineEventHandler, sendRedirect, setResponseHeader, createError, useRequestEvent, defineNuxtPlugin, useState, $fetch; unhead escapes "<" in JSON script innerHTML.
Seo API (Go): GET /_seo/api/v1/resolve?url= (200 for ok and not_found, X-Robots-Tag noindex), GET /_seo/api/v1/site, POST /_seo/api/v1/notfound {url, referer} with Authorization Bearer; GET /_seo/api/v1/verification/{name}; Go root routes /robots.txt, /llms.txt, /sitemap.xml; /_sitemap/files/{file}; /_sitemap/indexnow/{file}.
Copy: UI-SPEC L8 dictionary (en, pl) and the seo-tokens.css token table.
</interfaces>
</context>
## Execution units and resume points
- The repository is new: Task 2 runs `git init -b main ../vue-seo-layer`. Commit groups staged by path with `git -C ../vue-seo-layer add <paths>`. `pnpm install` (no new package beyond the pins the other layers use) creates pnpm-lock.yaml, committed with package.json. Every commit keeps `npm --prefix ../vue-seo-layer run test` green.
- Resume check: `git -C ../vue-seo-layer log --format=%s | grep -Fx '<subject>'` (a missing repository means Task 2 has not run).
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | none (checkpoint answer recorded in the SUMMARY) | answer recorded |
| 2 | `feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)` | smoke tracer lines pass |
| 3 | A `feat: redirect and 410 enforcement, X-Robots-Tag and the resolve cache`; B `feat: shared site cache purge route clears the SEO caches`; C `feat: root files from the CMS and the 404 report` | expect-seo.txt passes |
| 4 | A `feat: SEO labels, tokens and base styles`; B `docs: README` | plan complete |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the shared layer's name before it becomes a repository</name>
<reversibility rating="costly">Apps and the journal, menu and FAQ layers pin the layer by its git URL; a later rename means re-pinning every consumer.</reversibility>
<decision>D-97 leaves the name to confirm; the repository is created locally now and on git.golem15.com with tea in 33-14.</decision>
<context>Every other layer is named vue-<area>-layer (vue-journal-layer, vue-menu-layer, vue-faq-layer, vue-paymentgateway-layer). UI-SPEC and research use vue-seo-layer throughout. Its classes are .seo-*, its config key seo, its CSS tokens --seo-*.</context>
<options>
<option id="vue-seo-layer">
<name>vue-seo-layer</name>
<pros>Matches the naming of every other layer and of all phase documents</pros>
<cons>None known</cons>
</option>
<option id="other">
<name>Another name (stated in the answer)</name>
<pros>Jakub's choice</pros>
<cons>Plans 33-08 to 33-14 substitute the name; class and config names stay seo</cons>
</option>
</options>
<resume-signal>Reply "vue-seo-layer" or "other: <name>"</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "a page rendered by Nuxt carries the CMS title, canonical, hreflang and one JSON-LD graph from the resolve answer" — one path only</name>
<files>../vue-seo-layer/.gitignore, ../vue-seo-layer/.nvmrc, ../vue-seo-layer/package.json, ../vue-seo-layer/pnpm-lock.yaml, ../vue-seo-layer/pnpm-workspace.yaml, ../vue-seo-layer/tsconfig.json, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/app/plugins/seo-head.ts, ../vue-seo-layer/app/composables/useCmsSeo.ts, ../vue-seo-layer/app/composables/useSchemaNode.ts, ../vue-seo-layer/app/utils/graph.ts, ../vue-seo-layer/app/utils/pagePath.ts, ../vue-seo-layer/app/utils/types.ts, ../vue-seo-layer/server/middleware/seo.ts, ../vue-seo-layer/server/lib/resolve.ts, ../vue-seo-layer/server/api/__seo__/resolve.get.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/.playground/app/app.vue, ../vue-seo-layer/.playground/app/pages/index.vue, ../vue-seo-layer/.playground/app/pages/[...slug].vue, ../vue-seo-layer/testing/smoke-ssr.mjs, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/nuxt-server.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/graph.test.ts, ../vue-seo-layer/tests/pagePath.test.ts</files>
<read_first>
- ../vue-journal-layer/nuxt.config.ts, package.json, lib/journal-module.ts, testing/smoke-ssr.mjs, testing/fixture-api.mjs, testing/nuxt-server.mjs, server/plugins/journal-client-address.ts
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 5, Code Examples: layer head, Pitfall 2)
- .planning/phases/32-seo-foundation/32-03-SUMMARY.md and 32-04-SUMMARY.md (resolve answer and its 200/not_found behaviour)
</read_first>
<action>
Per D-97, D-43, D-55 and P33-26 to P33-29: `git init -b main ../vue-seo-layer` with the name from Task 1; package.json (name, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, `smoke:ssr` = `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt`; peer and dev nuxt 4.4.8, dev typescript 5.9.3 and vue 3.5.35 as the journal layer pins; no runtime dependency). nuxt.config.ts registers lib/seo-module.ts and the P33-27 runtime config. seo-module.ts (configKey seo) copies its options into runtimeConfig.public.seo, with the P33-27 defaults (links.newTabHint true per CONTEXT 2026-10-28 accepted defaults). `server/lib/resolve.ts`: `resolveCached(event, path)` fetching `${apiBase}/resolve?url=` with X-Forwarded-For set to the client address (journal-client-address pattern), returns null on any failure or when cms is false. `server/middleware/seo.ts` (Task 2 scope: page-path test with `isPagePath` from app/utils/pagePath.ts, resolve, `event.context.seo = res`). `app/utils/graph.ts`: pure `mergeGraph(cmsJsonld, nodes)` merging by @id. `useCmsSeo(answer)` applies useSeoMeta/useHead per RESEARCH Code Examples with keys canonical, alt-<hreflang> and jsonld, html lang and og:locale/alternate; `app/plugins/seo-head.ts` takes the answer from useRequestEvent().context.seo during SSR (stored in useState for hydration) and from `$fetch('/api/__seo__/resolve', {query: {url}})` on client navigation (`server/api/__seo__/resolve.get.ts` proxies through resolveCached). Playground: pages index and [...slug] rendering an H1; fixture resolve.json with an ok answer for /blog/lwia-grzywa (title, canonical, alternates pl/en/x-default, jsonld with two nodes), and site.json. smoke-ssr.mjs per P33-29; expect-seo.txt tracer lines: `200 /blog/lwia-grzywa contains=<title>Lwia grzywa count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=<html lang="pl"`. Unit tests graph.test.ts (merge by @id, layer node appended, no duplicate @id) and pagePath.test.ts (/_nuxt/x, /api/x, /favicon.ico skipped; /blog/x and / are pages). Commit as listed.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in the TAP output</fails_when>
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -5 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -15</automated>
<fails_when>non-zero exit from the build or the smoke, or a "FAIL" line in the smoke output</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../vue-seo-layer log --oneline | wc -l` is at least 1 and `git -C ../vue-seo-layer remote` prints nothing (no remote yet)
- `grep -n '"dependencies"' ../vue-seo-layer/package.json` prints nothing (no runtime dependency)
- `grep -n "application/ld+json" ../vue-seo-layer/app/composables/useCmsSeo.ts` prints one line
- the smoke tracer line asserts exactly one ld+json script and one canonical and passes
</acceptance_criteria>
<done>A Nuxt page extending the layer renders its head and one JSON-LD graph from the CMS answer.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Redirect/410/X-Robots-Tag enforcement, the resolve LRU with the moved purge route, root-file proxies and the 404 report</name>
<files>../vue-seo-layer/server/middleware/seo.ts, ../vue-seo-layer/server/middleware/seo-root.ts, ../vue-seo-layer/server/lib/resolve.ts, ../vue-seo-layer/server/lib/lru.ts, ../vue-seo-layer/server/lib/site.ts, ../vue-seo-layer/server/lib/siteCachePurge.ts, ../vue-seo-layer/server/api/__site__/cache/purge.post.ts, ../vue-seo-layer/server/plugins/site-cache-purge.ts, ../vue-seo-layer/server/plugins/seo-notfound.ts, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/purge-check.mjs, ../vue-seo-layer/testing/purge-token-check.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/lru.test.ts, ../vue-seo-layer/tests/sitePurge.test.ts</files>
<read_first>
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts, testing/purge-check.mjs, testing/purge-token-check.mjs (move unchanged in contract)
- ../sm-seo-plugin/controllers/api/notfound.go (report body and token) and classes/verification.go (file name pattern)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 5, Pitfall 6, Security Domain)
</read_first>
<behavior>
- /stary (fixture redirect 301 to https://example.test/nowy) → 301 with exactly that Location; /usuniety (gone) → 410; a page answer with x_robots_tag "noindex, nofollow" → that X-Robots-Tag header; /blog?page=abc (not_found, pagination.invalid) → 404; an unknown path (not_found without invalid) is left to Nuxt
- A request with a crafted Host or X-Forwarded-Host never changes the Location
- The fixture counts one resolve call for two requests to the same path within the TTL; after POST /api/__site__/cache/purge with the token, the next request resolves again; the purge route keeps its 404/401/proxied rules (purge-check.mjs and purge-token-check.mjs pass unchanged)
- Backend down: a page answers 200 without CMS tags (fail open)
- /robots.txt, /sitemap.xml, /sitemap-posts-1.xml, /llms.txt, /google1234.html (in root_files) and /<key>.txt (in root_files) are proxied with the backend's status, content type and X-Robots-Tag; /kontakt and /random.txt (not in root_files) pass through to Nuxt; a backend failure on a root file answers 503 with Retry-After 60
- A 404 page posts {url, referer} once to the fixture notfound endpoint with the bearer token; an unset token sends nothing
- seo.cms false: no resolve, no root proxies, no report
</behavior>
<action>
Per D-20, D-20a, D-103, D-05, D-08, D-22, D-25 and P33-27/P33-29: middleware enforcement (sendRedirect with res.location and res.status; createError 410; setResponseHeader x-robots-tag; 404 only for pagination.invalid), never reading Host for Location. `server/lib/lru.ts`: pure LRU with TTL, max entries and in-flight de-duplication (unit-tested). `server/lib/site.ts`: cached site answer (TTL 300 s). Move the purge route, lib and plugin from vue-journal-layer unchanged in behaviour (keep the header comment's rules) and make the purge also clear the resolve and site LRUs. Copy testing/purge-check.mjs and purge-token-check.mjs and adapt the content change to this layer: instead of a Journal entity (--kind/--slug), purge-check changes the title of the fixture resolve answer for --path in its temporary copy, and the playground's nuxt.config gets `routeRules: {'/**': {swr: 300}}` so pages are route-cached; every other check of both scripts stays. `server/middleware/seo-root.ts`: exact /robots.txt, /sitemap.xml, /llms.txt; regex ^/sitemap-[a-z0-9-]+\.xml$; one-segment names ending .html, .txt or .xml that the site answer lists in root_files; proxied per P33-26 paths with status, content type and X-Robots-Tag copied; off when rootFiles or cms is false. `server/plugins/seo-notfound.ts`: hook after the response is rendered (Nitro `afterResponse` or `render:response`) for status 404 page paths, fire-and-forget POST with the bearer token. Extend the fixture API and expect-seo.txt for each behavior line (header= and count= assertions). Tests first. Commit groups A, B, C.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -25 && node ../vue-seo-layer/testing/purge-check.mjs --output ../vue-seo-layer/.playground/.output --fixtures ../vue-seo-layer/testing/fixtures --path /blog/lwia-grzywa 2>&1 | tail -5 && node ../vue-seo-layer/testing/purge-token-check.mjs --output ../vue-seo-layer/.playground/.output --fixtures ../vue-seo-layer/testing/fixtures --path /blog/lwia-grzywa 2>&1 | tail -3</automated>
<fails_when>non-zero exit from any command, a "FAIL" line, or no "purge-check: ok" / "purge-token-check: ok" final line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "sendRedirect" ../vue-seo-layer/server/middleware/seo.ts` prints one line and `grep -n "getRequestHost\|x-forwarded-host" ../vue-seo-layer/server/middleware/seo.ts` prints nothing
- `grep -n "header=\|count=" ../vue-seo-layer/testing/expect-seo.txt` prints at least five lines
- expect-seo.txt has a line for each behavior bullet's status (301, 410, 404, 503, 200)
</acceptance_criteria>
<done>The layer enforces the CMS's redirect decision before caching, serves the site's root files from the CMS, reports 404s, and its caches purge with the site.</done>
</task>
<task type="auto">
<name>Task 4: Label dictionary, tokens and base styles; README</name>
<files>../vue-seo-layer/app/app.config.ts, ../vue-seo-layer/app/composables/useSeoLabels.ts, ../vue-seo-layer/app/utils/labels.ts, ../vue-seo-layer/app/assets/css/seo-tokens.css, ../vue-seo-layer/app/assets/css/seo-base.css, ../vue-seo-layer/nuxt.config.ts, ../vue-seo-layer/tests/labels.test.ts, ../vue-seo-layer/README.md</files>
<read_first>
- ../vue-menu-layer/app/assets/css/menu-tokens.css and ../vue-journal-layer/app/assets/css/journal-tokens.css (token conventions, :root:root overrides)
- ../vue-journal-layer/README.md (layer README structure)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L8, Public layers Spacing, Typography, Color tables, the layer dictionary)
</read_first>
<action>
Per UI-SPEC L8: `app/utils/labels.ts` holds the en and pl dictionaries (every key of the "Public layer dictionary" table, {name} placeholders) and a pure `pickLabels(locale, htmlLang, overrides)`; `useSeoLabels()` reads the resolve answer's locale and app.config `seo.labels`. seo-tokens.css defines every token of the public Spacing and Color tables (--seo-space-1..5, --seo-target-size, --seo-sitemap-column-width, --seo-color-muted, --seo-color-accent, --seo-color-on-accent, --seo-color-border, --seo-font-size-small, --seo-breadcrumbs-separator); seo-base.css styles only .seo-* classes (including .seo-visually-hidden, the standard clip pattern) and no bare element; nuxt.config css order tokens then base. labels.test.ts covers the locale fallback chain and overrides. README in the layer structure: what it does, install (pnpm git dependency pinned by tag), options (P33-27), runtime config and environment variables, the resolve contract (P33-26), root files, purge, 404 report, labels and tokens, testing. Commit groups A and B.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>grep -c "^\s*\.seo-" ../vue-seo-layer/app/assets/css/seo-base.css</automated>
<fails_when>output 0 or a missing file</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "\-\-seo-target-size\|\-\-seo-space-5" ../vue-seo-layer/app/assets/css/seo-tokens.css` prints two lines
- `grep -En "^\s*(a|h1|h2|body|nav|ol|li)\s*[{,]" ../vue-seo-layer/app/assets/css/seo-base.css` prints nothing (no bare-element rule)
- `grep -n "i18n" ../vue-seo-layer/package.json ../vue-seo-layer/nuxt.config.ts` prints nothing
</acceptance_criteria>
<done>The layer has its dictionary, tokens, base styles and documentation.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| visitor → Nitro middleware | untrusted path, query and headers |
| Nitro → Go SEO API | internal HTTP with forwarded client address |
| Go backend → purge route | bearer-token POST |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-29 | Spoofing (open redirect) | redirect enforcement | high | mitigate | Location only from the resolver's absolute answer (Phase 32 guarantees site-host targets unless an editor set an external target); no request header used; smoke line with a crafted Host |
| T-33-30 | Tampering (XSS) | JSON-LD script | high | mitigate | innerHTML through unhead, which escapes < in JSON scripts; the graph is JSON from the server, never concatenated markup |
| T-33-31 | Denial of service | resolve per request | medium | mitigate | LRU with TTL and in-flight de-duplication; X-Forwarded-For forwarded so the Go per-IP bucket applies per visitor |
| T-33-32 | Elevation of privilege (SSRF) | root-file proxy | high | mitigate | proxy targets are fixed backendBase/apiBase paths; only allow-listed names (regex and the site answer's root_files) are forwarded, never a user-supplied URL |
| T-33-33 | Spoofing | purge route | medium | mitigate | moved unchanged: 32+ character bearer token, proxied requests refused unless allowProxied; purge-check.mjs and purge-token-check.mjs |
| T-33-34 | Information disclosure | runtime config | medium | mitigate | tokens and apiBase live in server-only runtimeConfig, never under public (the payload test of purge-token-check.mjs) |
| T-33-SC | Tampering | npm installs | high | mitigate | no new package: package.json pins only nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3, the versions the existing layers already use; pnpm-lock.yaml committed |
</threat_model>
<verification>
- `npm --prefix ../vue-seo-layer run test` green after every commit.
- `npm --prefix ../vue-seo-layer run build:playground` and `npm --prefix ../vue-seo-layer run smoke:ssr` pass; purge-check.mjs passes.
</verification>
<success_criteria>
- The layer repository exists locally with the runtime: head from the CMS, one JSON-LD graph, redirect/410/X-Robots-Tag enforcement, root files, 404 reports, purge, labels and tokens.
- No push; the remote is created in 33-14.
</success_criteria>
## Artifacts this phase produces
- Repository `../vue-seo-layer` (local, branch main).
- Module: `lib/seo-module.ts` (configKey `seo`; options `cms`, `breadcrumbs`, `links.newTabHint` (default true, CONTEXT 2026-10-28 accepted defaults), `rootFiles`, `resolveFiles`).
- Runtime config: `seo.apiBase`, `seo.backendBase`, `seo.notfoundToken`, `seo.routeReportToken`, `seo.resolveCacheTtl`, `cachePurge.token`, `cachePurge.allowProxied`, `siteCache.purgeToken`, `siteCache.allowProxied`; env `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`, `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`.
- Composables and utils: `useCmsSeo`, `useSchemaNode`, `useSeoLabels`, `mergeGraph`, `isPagePath`, `pickLabels`.
- Server: middleware `seo.ts`, `seo-root.ts`; libs `resolve.ts` (`resolveCached`), `lru.ts`, `site.ts`, `siteCachePurge.ts`; routes `GET /api/__seo__/resolve`, `POST /api/__site__/cache/purge`; plugins `site-cache-purge.ts`, `seo-notfound.ts`.
- CSS: `seo-tokens.css`, `seo-base.css` (`.seo-visually-hidden`).
- Testing: `testing/smoke-ssr.mjs` with `count=` and `header=`, fixture API, `testing/expect-seo.txt`, unit tests `graph`, `pagePath`, `lru`, `labels`, `sitePurge`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-07-SUMMARY.md` when done; record the confirmed layer name at the top.
</output>

View File

@@ -0,0 +1,307 @@
---
phase: 33-seo-output-audit-and-content
plan: 08
type: execute
wave: 2
depends_on: ["33-07"]
files_modified:
- ../vue-seo-layer/lib/seo-module.ts
- ../vue-seo-layer/lib/guard.ts
- ../vue-seo-layer/lib/routes.ts
- ../vue-seo-layer/nuxt.config.ts
- ../vue-seo-layer/app/plugins/seo-route-robots.ts
- ../vue-seo-layer/app/components/Seo/Breadcrumbs.vue
- ../vue-seo-layer/app/components/Seo/Pagination.vue
- ../vue-seo-layer/app/components/Seo/SitemapLink.vue
- ../vue-seo-layer/app/components/global/SeoHtmlSitemapPage.vue
- ../vue-seo-layer/app/seo-pages/SitemapPage.vue
- ../vue-seo-layer/app/seo-pages/SitemapGroupPage.vue
- ../vue-seo-layer/app/composables/usePagination.ts
- ../vue-seo-layer/app/composables/useSeoSite.ts
- ../vue-seo-layer/app/utils/paginationWindow.ts
- ../vue-seo-layer/app/utils/pageHref.ts
- ../vue-seo-layer/app/utils/breadcrumbClaim.ts
- ../vue-seo-layer/app/assets/css/seo-base.css
- ../vue-seo-layer/server/api/__seo__/sitemap.get.ts
- ../vue-seo-layer/server/api/__seo__/site.get.ts
- ../vue-seo-layer/server/plugins/seo-route-report.ts
- ../vue-seo-layer/server/plugins/seo-drift.ts
- ../vue-seo-layer/.playground/nuxt.config.ts
- ../vue-seo-layer/.playground/app/pages/blog/index.vue
- ../vue-seo-layer/.playground/app/pages/konto.vue
- ../vue-seo-layer/testing/fixture-api.mjs
- ../vue-seo-layer/testing/expect-seo.txt
- ../vue-seo-layer/testing/fixtures/seo/resolve.json
- ../vue-seo-layer/testing/fixtures/seo/site.json
- ../vue-seo-layer/testing/fixtures/seo/html.json
- ../vue-seo-layer/testing/guard-check.mjs
- ../vue-seo-layer/testing/guard/indexable-ssr-false/nuxt.config.ts
- ../vue-seo-layer/testing/guard/noindex-ok/nuxt.config.ts
- ../vue-seo-layer/testing/guard/client-page/nuxt.config.ts
- ../vue-seo-layer/testing/guard/client-page/app/pages/panel.client.vue
- ../vue-seo-layer/testing/guard/global-ssr-false/nuxt.config.ts
- ../vue-seo-layer/package.json
- ../vue-seo-layer/tests/guard.test.ts
- ../vue-seo-layer/tests/paginationWindow.test.ts
- ../vue-seo-layer/tests/pageHref.test.ts
- ../vue-seo-layer/tests/breadcrumbClaim.test.ts
- ../vue-seo-layer/README.md
autonomous: true
requirements: [SC-2, D-70, D-23, D-21, D-33, D-20c, D-86, D-40, D-41, D-42, D-71, D-34, D-97]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 3
confidence: low
must_haves:
truths:
- "nuxt build fails with \"[vue-seo-layer] indexable routes must render on the server (D-70): <routes>\" when a route rule sets ssr: false, a page is a .client.vue page, or the app sets ssr: false, unless the route is declared in seo.noindexRoutes (or the app sets seo.indexable: false); rules from nuxt.options.routeRules, nitro routeRules and every layer are checked after the layers merge, and experimental.inlineRouteRules is refused (D-70, RESEARCH Pattern 6, Pitfall 10)"
- "A route in seo.noindexRoutes gets X-Robots-Tag: noindex, nofollow through its route rule and a robots noindex,nofollow meta; a page whose meta sets requiresAuth: true or seo.robots gets the same automatically without page code, which is how apps mark signed-in and auth pages (CONTEXT 2026-10-28 accepted defaults: auth pages are marked through route meta; no user layer) (D-23, D-21, D-08)"
- "At server start the layer reports its static routes (paths without parameters, outside noindexRoutes) to POST /_seo/api/v1/registry/routes with the route-report token, once per process with one retry after 60 s, so no frontend page is missing from the registry (D-33)"
- "At server start the layer compares the CMS's content-type URL patterns, pagination form and segments and HTML-sitemap paths (from /_seo/api/v1/site) with its build-time routes and options, and logs one warning per mismatch (D-20c drift check; RESEARCH Open Question 9, resolved by UI-SPEC L3: build-time options plus a startup drift check)"
- "<SeoBreadcrumbs> renders the resolve answer's breadcrumbs as nav > ol with server-rendered <a href> links and the last item as aria-current=\"page\" text, separators CSS-generated; it renders nothing with fewer than two items, when page meta sets seo.breadcrumbs false or when the option seo.breadcrumbs is false; only the first instance per request renders (D-86, D-71, UI-SPEC L1)"
- "<SeoPagination> with usePagination() renders numbered server-rendered links in the CMS-chosen form (query or path, per-locale segment), keeps other query parameters, maps page 1 to the unnumbered URL, shows five pages around the current one plus page 1 and the last page with gap items, omits previous/next at the ends, adds <link rel=prev/next> to the head and replaces %pages% in the CMS title and description; it renders nothing when last ≤ 1 (D-40, D-41, D-42, D-71, UI-SPEC L2)"
- "The HTML sitemap is served at each locale's html_path (defaults /mapa-strony pl, /sitemap en) with group pages at <html_path>/<group>; it lists the sitemap plugin's HTML groups (an H1 page title, an H2 per group, categories nested, large groups linking to their paginated group page), answers 404 for an unknown group or a page past the last, and a failed API call answers 503 so no empty page is cached (D-34, UI-SPEC L3)"
- "<SeoSitemapLink> renders the footer link to the locale's HTML sitemap only when the HTML sitemap is enabled (D-34, UI-SPEC L4)"
- "The HTML sitemap overview omits empty groups and shows sitemap.empty when every group is empty (UI-SPEC empty: HTML sitemap)"
- "Breadcrumbs render nothing on the home page and pagination renders nothing with one page (UI-SPEC empty: breadcrumbs, pagination)"
- "An HTML-sitemap API failure answers 503 through createError, never an empty cached page (UI-SPEC error: HTML sitemap API down)"
- "Long breadcrumb names and sitemap titles wrap (overflow-wrap anywhere) and are never truncated; the full text stays in the HTML (UI-SPEC long-text: public)"
- "A group larger than html_page_size shows its first page plus the sitemap.all link on the overview, and its group page is paginated with <SeoPagination> (UI-SPEC zero-one-many: large HTML sitemap groups)"
- {statement: "Pagination window: current=5, last=14 yields 1 … 3 4 5 6 7 … 14; current=1, last=3 yields 1 2 3 with no gap; the page 1 href has no number in both forms (UI-SPEC L2 backstop)", verification: backstop}
- {statement: "A layout instance plus a page instance of <SeoBreadcrumbs> render exactly one nav.seo-breadcrumbs; the last item has aria-current=\"page\" and no link (UI-SPEC L1 backstop)", verification: backstop}
prohibitions:
- requirement_id: "D-70"
category: safety
status: resolved
verification: test
statement: "The build guard cannot be passed by moving ssr: false into a layer, nitro routeRules or a .client.vue page; only an explicit noindex declaration lets a client-only route build"
- requirement_id: "D-23"
category: values
status: resolved
verification: judgment
statement: "No route is marked noindex unless the app declared it (noindexRoutes) or its page meta asks for it; the layer never noindexes indexable pages by default"
artifacts:
- path: "../vue-seo-layer/lib/guard.ts"
provides: "pure build-guard checks (isNoindex, offenders)"
contains: "D-70"
- path: "../vue-seo-layer/testing/guard-check.mjs"
provides: "fixture builds expecting exit 1/0"
contains: "indexable-ssr-false"
- path: "../vue-seo-layer/app/components/Seo/Pagination.vue"
provides: "L2"
contains: "rel"
- path: "../vue-seo-layer/app/components/Seo/Breadcrumbs.vue"
provides: "L1"
contains: "aria-current"
key_links:
- from: "../vue-seo-layer/lib/seo-module.ts"
to: "../vue-seo-layer/lib/guard.ts"
via: "nuxt.hook('pages:resolved') collects pages; nuxt.hook('nitro:config') merges route rules and throws on offenders"
pattern: "nitro:config"
- from: "../vue-seo-layer/server/plugins/seo-route-report.ts"
to: "sm-seo-plugin POST /_seo/api/v1/registry/routes"
via: "the build-time static route list posted with the route-report token"
pattern: "registry/routes"
- from: "../vue-seo-layer/app/composables/usePagination.ts"
to: "../vue-seo-layer/app/utils/pageHref.ts"
via: "hrefs in the site answer's pagination form"
pattern: "pageHref"
---
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan gives the shared layer the SSR build guard, automatic noindex for declared and signed-in routes, the route report and drift check, and the public components: breadcrumbs, pagination, the HTML sitemap page and its footer link.
<objective>
Extend ../vue-seo-layer (after 33-07; main, not pushed):
- the SSR build guard and its fixture playgrounds (D-70);
- noindex for declared routes and pages whose meta requires sign-in (D-23, D-21);
- the route report to the registry and the startup drift check (D-33, D-20c);
- L1 `<SeoBreadcrumbs>` (D-86), L2 `<SeoPagination>` + `usePagination()` (D-40..42), L3 HTML sitemap pages and L4 `<SeoSitemapLink>` (D-34).
Purpose: SC-2 ("a build fails when an indexable route has ssr: false"; breadcrumbs and pagination from the CMS).
Output: commits in ../vue-seo-layer.
**Planner decisions recorded here:**
- **P33-30 (module options added):** `noindexRoutes: string[]` (route-rule patterns such as `/p/**`), `indexable: boolean` (default true), `routeReport: boolean` (default true), `pagination: {form: 'query' | 'path', param: 'page', segments: Record<locale, string>}` (default query, pl strona, en page), `htmlSitemap: {paths: Record<locale, string>} | false` (default pl /mapa-strony, en /sitemap); all copied into runtimeConfig.public.seo; the drift check compares them with the CMS.
- **P33-31 (route artefact):** the module writes the static route list and every page route pattern into a server template (`#seo-routes`, addServerTemplate) read by the report and drift plugins; no network call at build time.
- **P33-32 (HTML sitemap pages):** registered with extendPages per locale (names `seo-sitemap-<locale>` and `seo-sitemap-group-<locale>`), each page file rendering the global component `SeoHtmlSitemapPage`, which an app overrides by registering its own component of that name; data through `GET /api/__seo__/sitemap?locale=&group=&page=` proxying `/_sitemap/api/v1/html`; group-page breadcrumbs are built in the layer from home_crumb, sitemap.title and the group title, since the resolver does not know group pages.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-07-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-07: lib/seo-module.ts (configKey seo, runtimeConfig.public.seo), server/lib/resolve.ts resolveCached, server/lib/site.ts (cached /site answer), useCmsSeo, useSeoLabels (L8 keys breadcrumbs.label, pagination.label/previous/next/page, sitemap.title/groupTitle/pageSuffix/all/empty, sitemapLink), testing/smoke-ssr.mjs with count= and header=, fixture API, tokens in seo-tokens.css.
Contracts (P33-26): resolve breadcrumbs [{name, url, path}], pagination {form, param, segment, page, invalid}; site pagination {form, param, segments}, home_crumb, html_sitemap {enabled, paths, page_size}, routes [{type, pattern}]; sitemap HTML API overview {groups[{key, slug, title, total, entries[{title, url, children?}], more}]} and group page {group, title, page, pages, entries}.
Nuxt hooks: pages:resolved (page.mode === 'client' for .client.vue), nitro:config, extendPages, addServerTemplate (nuxt/kit); routeRules ssr false; experimental.inlineRouteRules.
Existing journal pagination for reference: ../vue-journal-layer/app/utils/pagination.ts (paginationWindow) and app/components/Journal/Pagination.vue.
Demo app real case: ../sm-demo-app/vue-demo-app/nuxt.config.ts routeRules '/p/**' and '/presentations/**' ssr false (33-12 declares them noindex).
Copy: UI-SPEC L1–L4 markup, defaults and the L8 dictionary.
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path with `git -C ../vue-seo-layer add <paths>`; `npm --prefix ../vue-seo-layer run test` green at every commit.
- Resume check: `git -C ../vue-seo-layer log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: the build fails when an indexable route renders only in the browser (tracer)` | guard-check.mjs passes |
| 2 | A `feat: noindex for declared and signed-in routes`; B `feat: route report and drift check at server start` | smoke lines for noindex and report pass |
| 3 | A `feat: breadcrumbs component`; B `feat: pagination component and composable`; C `feat: HTML sitemap pages and footer link`; D `docs: build guard, noindex routes and components` | plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an app with an indexable ssr:false route fails its build, and the same route declared noindex builds and is served with X-Robots-Tag" — one path only</name>
<files>../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/lib/guard.ts, ../vue-seo-layer/testing/guard-check.mjs, ../vue-seo-layer/testing/guard/indexable-ssr-false/nuxt.config.ts, ../vue-seo-layer/testing/guard/noindex-ok/nuxt.config.ts, ../vue-seo-layer/testing/guard/client-page/nuxt.config.ts, ../vue-seo-layer/testing/guard/client-page/app/pages/panel.client.vue, ../vue-seo-layer/testing/guard/global-ssr-false/nuxt.config.ts, ../vue-seo-layer/package.json, ../vue-seo-layer/tests/guard.test.ts</files>
<read_first>
- ../vue-seo-layer/lib/seo-module.ts and nuxt.config.ts (33-07)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 6, Pitfall 10)
- ../sm-demo-app/vue-demo-app/nuxt.config.ts (routeRules with ssr false, the real case)
</read_first>
<action>
Per D-70 and P33-30: `lib/guard.ts` (pure, unit-tested): `isNoindex(path, noindexRoutes)` with Nitro route-rule pattern semantics (`/x/**`, `/x/*`, exact), `findOffenders({ssr, indexable, routeRules, pages, noindexRoutes, inlineRouteRules})` returning the offender list (global ssr false; each rule with ssr false not covered; each client-mode page not covered; inlineRouteRules true is itself an offender with a message to remove it). seo-module.ts collects pages in `pages:resolved`, and in `nitro:config` merges `nuxt.options.routeRules` with `nitro.routeRules`, throws `[vue-seo-layer] indexable routes must render on the server (D-70): <list>` when offenders exist, and adds `headers: {'x-robots-tag': 'noindex, nofollow'}` to each noindexRoutes pattern's rule. Fixture apps under testing/guard extend the layer (`extends: ['../../..']`): indexable-ssr-false (`'/x/**': {ssr: false}`), noindex-ok (the same plus `seo: {noindexRoutes: ['/x/**']}`), client-page (`app/pages/panel.client.vue`), global-ssr-false (`ssr: false`). `testing/guard-check.mjs` runs `nuxt build` for each fixture (the layer's node_modules nuxt binary, cwd the fixture), expects exit ≠ 0 and the D-70 message for the three offenders and exit 0 for noindex-ok, then starts noindex-ok's server and expects `X-Robots-Tag: noindex, nofollow` on /x/a; prints `guard-check: ok (<n> checks)` or `guard-check: FAIL ...` and exits 1. package.json script `guard:check`. Commit as listed.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>npm --prefix ../vue-seo-layer run guard:check 2>&1 | tail -8</automated>
<fails_when>non-zero exit, a "guard-check: FAIL" line, or no "guard-check: ok" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "indexable routes must render on the server (D-70)" ../vue-seo-layer/lib/guard.ts ../vue-seo-layer/lib/seo-module.ts` prints at least one line
- `grep -n "nitro:config\|pages:resolved" ../vue-seo-layer/lib/seo-module.ts` prints two lines
- guard-check.mjs covers four fixtures and the header check
</acceptance_criteria>
<done>No app on the layer can ship an indexable page that renders only in the browser.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Noindex for declared and signed-in routes, the route report and the startup drift check</name>
<files>../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/lib/routes.ts, ../vue-seo-layer/app/plugins/seo-route-robots.ts, ../vue-seo-layer/server/plugins/seo-route-report.ts, ../vue-seo-layer/server/plugins/seo-drift.ts, ../vue-seo-layer/server/api/__seo__/site.get.ts, ../vue-seo-layer/app/composables/useSeoSite.ts, ../vue-seo-layer/.playground/nuxt.config.ts, ../vue-seo-layer/.playground/app/pages/konto.vue, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/testing/fixtures/seo/site.json, ../vue-seo-layer/tests/guard.test.ts</files>
<read_first>
- ../vue-seo-layer/server/lib/site.ts and server/plugins/site-cache-purge.ts (33-07 server plugin style)
- ../sm-seo-plugin/controllers/api/registry.go (33-05 route report body and token) — or 33-05 P33-20 if not yet built
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Open Questions 9, 10)
</read_first>
<behavior>
- The playground page /konto with definePageMeta({requiresAuth: true}) renders meta robots "noindex, nofollow" and the header X-Robots-Tag noindex, nofollow; a page with seo: {robots: 'noindex, follow'} renders that value; ordinary pages keep the CMS robots
- The fixture API receives one POST /_seo/api/v1/registry/routes at server start with the bearer token and the static paths (/ and /blog); /konto (meta requiresAuth) and paths under noindexRoutes are left out; without a token or with routeReport false nothing is sent
- With a fixture site answer whose journal-post pattern has no Nuxt route, whose pagination form is path while the build option is query, and whose html path differs, the server log has three "[vue-seo-layer] drift:" warnings and the server still starts
- useSeoSite() returns the cached site answer on server and client (proxy /api/__seo__/site)
</behavior>
<action>
Per D-23, D-21, D-33, D-20c, P33-30/P33-31 and the CONTEXT 2026-10-28 accepted default (auth pages are marked through route meta; no user layer): the module adds `requiresAuth` and `seo` to `nuxt.options.experimental.extraPageMetaExtractionKeys`, so pages:resolved carries those meta keys at build time; `lib/routes.ts` (pure) turns resolved pages into static paths (leaving out pages whose meta has requiresAuth or a noindex seo.robots, and noindexRoutes) and route patterns (`:slug()` and `[...slug]` normalised to `:name`, catch-alls marked); the module writes `#seo-routes` with addServerTemplate. `app/plugins/seo-route-robots.ts`: on every route, if the matched route's meta has requiresAuth true or seo.robots, apply useSeoMeta robots and, during SSR, setResponseHeader x-robots-tag; noindexRoutes matches (isNoindex) get the same meta. `server/plugins/seo-route-report.ts` and `seo-drift.ts` run once after Nitro starts (non-blocking), honour cms and routeReport options and the tokens of P33-27, and log results. `server/api/__seo__/site.get.ts` proxies the cached site answer; `useSeoSite()` wraps useFetch of it with a stable key. Extend fixtures and expect-seo.txt (header and meta lines, the report recorded by the fixture API checked by smoke-ssr through a `report=` expectation or a post-run assertion in fixture-api.mjs). Tests first. Commit groups A and B.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -20</automated>
<fails_when>non-zero exit, or a "FAIL" line in the smoke output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "requiresAuth" ../vue-seo-layer/app/plugins/seo-route-robots.ts` prints at least one line
- `grep -n "registry/routes" ../vue-seo-layer/server/plugins/seo-route-report.ts` prints one line
- `grep -n "drift:" ../vue-seo-layer/server/plugins/seo-drift.ts` prints at least one line
- expect-seo.txt asserts the /konto header and meta
</acceptance_criteria>
<done>Signed-in and declared routes are noindex without page code, the registry learns the site's pages, and drift between CMS and layer is visible at start.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Breadcrumbs, pagination, the HTML sitemap pages and the footer link</name>
<files>../vue-seo-layer/app/components/Seo/Breadcrumbs.vue, ../vue-seo-layer/app/components/Seo/Pagination.vue, ../vue-seo-layer/app/components/Seo/SitemapLink.vue, ../vue-seo-layer/app/components/global/SeoHtmlSitemapPage.vue, ../vue-seo-layer/app/seo-pages/SitemapPage.vue, ../vue-seo-layer/app/seo-pages/SitemapGroupPage.vue, ../vue-seo-layer/app/composables/usePagination.ts, ../vue-seo-layer/app/utils/paginationWindow.ts, ../vue-seo-layer/app/utils/pageHref.ts, ../vue-seo-layer/app/utils/breadcrumbClaim.ts, ../vue-seo-layer/app/assets/css/seo-base.css, ../vue-seo-layer/lib/seo-module.ts, ../vue-seo-layer/server/api/__seo__/sitemap.get.ts, ../vue-seo-layer/.playground/app/pages/blog/index.vue, ../vue-seo-layer/testing/fixture-api.mjs, ../vue-seo-layer/testing/fixtures/seo/html.json, ../vue-seo-layer/testing/fixtures/seo/resolve.json, ../vue-seo-layer/testing/expect-seo.txt, ../vue-seo-layer/tests/paginationWindow.test.ts, ../vue-seo-layer/tests/pageHref.test.ts, ../vue-seo-layer/tests/breadcrumbClaim.test.ts, ../vue-seo-layer/README.md</files>
<read_first>
- ../vue-journal-layer/app/components/Journal/Pagination.vue and app/utils/pagination.ts (markup, class names, window)
- ../vue-seo-layer/app/utils/labels.ts and app/assets/css/seo-tokens.css (33-07)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L1, L2, L3, L4, Public layers tables, Focal points)
</read_first>
<behavior>
- paginationWindow(5, 14) → [1, gap, 3, 4, 5, 6, 7, gap, 14]; (1, 3) → [1, 2, 3]; (14, 14) → [1, gap, 10, 11, 12, 13, 14]
- pageHref('/blog', 1, query) → /blog; ('/blog?sort=new', 3, query) → /blog?sort=new&page=3; ('/blog', 3, path, 'strona') → /blog/strona/3; ('/en/blog', 1, path, 'page') → /en/blog
- Smoke: /blog?page=5 with last 14 renders nav.seo-pagination with rel=prev and rel=next anchors, aria-current on 5, the 1 and 14 links, two gap items, head <link rel="prev"> and <link rel="next">, and the title without the literal %pages%; /blog with one page renders no nav.seo-pagination
- Smoke: a post page renders exactly one nav.seo-breadcrumbs (layout + page instance) with <a href="/"> first and the last item as span aria-current="page"; the home page renders none
- Smoke: /mapa-strony renders H1 "Mapa strony" and one H2 per non-empty group, a group with more=true renders the sitemap.all link; /mapa-strony/posts?page=2 renders the group page with pagination; /mapa-strony/nope → 404; with the fixture API failing → 503; with every group empty → sitemap.empty
- SitemapLink renders the locale path only when html_sitemap.enabled
</behavior>
<action>
Per D-86, D-40 to D-42, D-34, D-71 and UI-SPEC L1–L4 (markup, classes, props, defaults, copy) with P33-32: Breadcrumbs.vue (props items, label, hideCurrent; NuxtLink for each crumb's path; the first-instance claim through `breadcrumbClaim.ts`, a request-scoped flag reset on every route change; renders nothing per the L1 rules); Pagination.vue (props current, last, labels, classPrefix default seo-pagination; markup of L2 with aria-labels from labels; hrefs from usePagination); usePagination({current, last}) builds hrefs with pageHref from the site answer's pagination (fallback to the build option), adds head links rel prev/next and replaces %pages% in the CMS title and description; HTML sitemap pages registered by the module from htmlSitemap.paths (P33-32) rendering SeoHtmlSitemapPage, which fetches /api/__seo__/sitemap (server proxy to the sitemap API with 404 passthrough and 503 on failure) and renders L3 markup (H1, section per group with H2, nested ul for categories, sitemap.all link, group page with H1 + pageSuffix on page ≥ 2 and SeoPagination, breadcrumbs from home_crumb, sitemap.title and the group title); SitemapLink.vue per L4. seo-base.css adds the L1, L2 and L3 default styles of UI-SPEC (only .seo-* selectors, tokens only, overflow-wrap anywhere). Fixtures and expect-seo.txt per behavior list; unit tests first. README documents components, props, options and the override of SeoHtmlSitemapPage. Commit groups A to D.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>npm --prefix ../vue-seo-layer run build:playground 2>&1 | tail -3 && npm --prefix ../vue-seo-layer run smoke:ssr 2>&1 | tail -30 && npm --prefix ../vue-seo-layer run guard:check 2>&1 | tail -3</automated>
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "aria-current" ../vue-seo-layer/app/components/Seo/Breadcrumbs.vue ../vue-seo-layer/app/components/Seo/Pagination.vue` prints a line in each
- `grep -n "ClientOnly\|onMounted" ../vue-seo-layer/app/components/Seo/*.vue ../vue-seo-layer/app/components/global/*.vue ../vue-seo-layer/app/seo-pages/*.vue` prints nothing (server-rendered only, UI-SPEC L8)
- `grep -n "count=nav class=\"seo-breadcrumbs\|count=<nav class=\"seo-breadcrumbs" ../vue-seo-layer/testing/expect-seo.txt` prints at least one line (exactly-one assertion)
- unit tests cover every behavior example of paginationWindow and pageHref
</acceptance_criteria>
<done>Sites get server-rendered breadcrumbs, pagination and an HTML sitemap from the CMS, with the D-71 link rules.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| app config → build guard | build-time configuration |
| frontend server → registry and site endpoints | token-authenticated and public calls |
| visitor query → pagination hrefs | page numbers and other query parameters echoed into links |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-35 | Tampering (XSS) | pagination and breadcrumbs hrefs | medium | mitigate | hrefs built with URL/URLSearchParams and rendered through Vue bindings (escaped), never v-html; breadcrumb names from the CMS rendered as text |
| T-33-36 | Spoofing | route report token | medium | mitigate | token only in server-only runtime config; sent only to the configured apiBase |
| T-33-37 | Repudiation/Integrity | build guard bypass | medium | mitigate | rules checked after layer merge from both sources plus client pages; inline route rules refused; guard-check fixtures |
| T-33-38 | Denial of service | HTML sitemap proxy | low | mitigate | page size capped by the sitemap API; 503 on failure prevents caching an empty page |
| T-33-SC | Tampering | npm installs | high | accept | no new package |
</threat_model>
<verification>
- `npm --prefix ../vue-seo-layer run test`, `build:playground`, `smoke:ssr` and `guard:check` pass.
</verification>
<success_criteria>
- The build guard fails indexable client-only routes and passes declared noindex ones.
- Declared and signed-in routes are noindex automatically; routes are reported and drift is logged.
- Breadcrumbs, pagination and the HTML sitemap render server-side from the CMS.
</success_criteria>
## Artifacts this phase produces
- Module options: `noindexRoutes`, `indexable`, `routeReport`, `pagination` (`form`, `param`, `segments`), `htmlSitemap` (`paths`).
- Libs: `lib/guard.ts` (`isNoindex`, `findOffenders`), `lib/routes.ts`; server template `#seo-routes`.
- Components: `<SeoBreadcrumbs>`, `<SeoPagination>`, `<SeoSitemapLink>`, global `SeoHtmlSitemapPage`; pages `seo-sitemap-<locale>`, `seo-sitemap-group-<locale>`.
- Composables and utils: `usePagination`, `useSeoSite`, `paginationWindow`, `pageHref`, `breadcrumbClaim`.
- Server: `GET /api/__seo__/sitemap`, `GET /api/__seo__/site`; plugins `seo-route-report.ts`, `seo-drift.ts`; app plugin `seo-route-robots.ts`.
- Page meta keys honoured: `requiresAuth`, `seo.robots`, `seo.breadcrumbs`.
- Scripts: `guard:check` (`testing/guard-check.mjs`) and the fixtures under `testing/guard/`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-08-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,378 @@
---
phase: 33-seo-output-audit-and-content
plan: 09
type: execute
wave: 3
depends_on: ["33-08", "33-02"]
files_modified:
- ../vue-seo-layer/app/utils/bodyLinks.ts
- ../vue-seo-layer/tests/bodyLinks.test.ts
- ../vue-seo-layer/README.md
- ../vue-journal-layer/package.json
- ../vue-journal-layer/pnpm-lock.yaml
- ../vue-journal-layer/nuxt.config.ts
- ../vue-journal-layer/lib/journal-module.ts
- ../vue-journal-layer/app/utils/types.ts
- ../vue-journal-layer/app/utils/routes.ts
- ../vue-journal-layer/app/utils/seo.ts
- ../vue-journal-layer/app/utils/htmlGate.ts
- ../vue-journal-layer/app/utils/sitemap.ts
- ../vue-journal-layer/app/components/Journal/PostCard.vue
- ../vue-journal-layer/app/components/Journal/PostBody.vue
- ../vue-journal-layer/app/components/Journal/Pagination.vue
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
- ../vue-journal-layer/app/components/Journal/RelatedPosts.vue
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
- ../vue-journal-layer/app/journal-pages/PostPage.vue
- ../vue-journal-layer/app/journal-pages/PostIndexPage.vue
- ../vue-journal-layer/app/journal-pages/CategoryPage.vue
- ../vue-journal-layer/app/journal-pages/TagPage.vue
- ../vue-journal-layer/app/journal-pages/AuthorPage.vue
- ../vue-journal-layer/app/assets/css/journal-tokens.css
- ../vue-journal-layer/app/assets/css/journal-base.css
- ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
- ../vue-journal-layer/server/lib/siteCachePurge.ts
- ../vue-journal-layer/server/plugins/site-cache-purge.ts
- ../vue-journal-layer/server/api/__sitemap__/journal.ts
- ../vue-journal-layer/tests/seo.test.ts
- ../vue-journal-layer/tests/sitemap.test.ts
- ../vue-journal-layer/tests/sitePurge.test.ts
- ../vue-journal-layer/tests/htmlGate.test.ts
- ../vue-journal-layer/tests/routes.test.ts
- ../vue-journal-layer/testing/smoke-ssr.mjs
- ../vue-journal-layer/testing/fixture-api.mjs
- ../vue-journal-layer/testing/expect-acme.txt
- ../vue-journal-layer/testing/expect-acme-title-h1.txt
- ../vue-journal-layer/testing/fixtures/acme/posts.json
- ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json
- ../vue-journal-layer/testing/fixtures/acme/seo/site.json
- ../vue-journal-layer/README.md
- ../vue-menu-layer/package.json
- ../vue-menu-layer/pnpm-lock.yaml
- ../vue-menu-layer/nuxt.config.ts
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
- ../vue-menu-layer/server/lib/siteCachePurge.ts
- ../vue-menu-layer/server/plugins/site-cache-purge.ts
- ../vue-menu-layer/tests/sitePurge.test.ts
- ../vue-menu-layer/README.md
- ../vue-faq-layer/package.json
- ../vue-faq-layer/pnpm-lock.yaml
- ../vue-faq-layer/nuxt.config.ts
- ../vue-faq-layer/app/components/Faq/Block.vue
- ../vue-faq-layer/app/utils/faqSchema.ts
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
- ../vue-faq-layer/server/lib/siteCachePurge.ts
- ../vue-faq-layer/server/plugins/site-cache-purge.ts
- ../vue-faq-layer/tests/sitePurge.test.ts
- ../vue-faq-layer/tests/faqSchema.test.ts
- ../vue-faq-layer/testing/expect.txt
- ../vue-faq-layer/README.md
- ../vue-journal-layer/testing/fixtures/acme/categories.json
- ../sm-journal-plugin/classes/related.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_helpers.go
- ../sm-journal-plugin/config/config.yaml
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/related_media_test.go
autonomous: true
requirements: [SC-2, D-97, D-50, D-52, D-80, D-81, D-82, D-83, D-71, D-86, D-40, D-41, D-43]
estimate:
tokens: 200000
raw_tokens: 200000
tasks: 4
confidence: low
must_haves:
truths:
- "vue-journal-layer, vue-menu-layer and vue-faq-layer extend the shared SEO layer; the journal layer no longer uses @nuxtjs/seo (no nuxt-schema-org graph, no second canonical, no robots or sitemap module), and its own meta applies only as a fallback when no CMS answer exists (D-97, RESEARCH Pitfall 2)"
- "The post page title is an h1 while the Journal API reports content_h1_to_h2 true and a p.journal-post__title (same class, same look through the journal post-title tokens) while it is false; the option journal.postTitleTag ('auto' | 'h1' | 'p', default 'auto') overrides it either way (2026-10-28 layer options: postTitleTag)"
- "The journal body gate demotes h1 to h2 only while the Journal API reports content_h1_to_h2 true, read from the API, not from a layer option; while false the body keeps its h1 (2026-10-28 Journal H1 setting, D-50, UI-SPEC L7)"
- "Category pillar text follows the same rule: the category page demotes an h1 in it while the category row's content_h1_to_h2 is true (absent counts as true) and keeps it while false (CONTEXT 2026-10-28 H1 conversion scope: post content and category pillar text alike)"
- "Every page of the fixture sites has exactly one <h1 and no skipped heading level: listing headers are h1 (page ≥ 2 keeps the · page suffix), post cards are h2 (h3 inside related posts under their h2), FAQ blocks with headingLevel 0 render a styled <p class=\"faq-block__title\"> for sidebar and footer widgets, menus and breadcrumbs have no headings (D-52, UI-SPEC L6)"
- "Each post card has exactly one anchor, the title link: the image is no longer wrapped in a link, the separate read-more link is gone, the whole card stays clickable through a CSS stretched link with a visible focus ring, and post links carry no rel (D-80, D-81, UI-SPEC L5)"
- "Body HTML is rendered at SSR in the L7 order: H1 demotion by setting, phrase auto-links from the resolve answer (first occurrence, at most autolink_max per text, whole words, case-insensitive, never inside headings, links, code, pre, figcaption or button, never to the page itself), outbound rules (editor rel kept, nofollow or sponsored added from the domain lists; target=_blank and rel=noopener only while outbound.new_tab is true, plus the visually hidden new-tab hint while the SEO-layer option seo.links.newTabHint is also true, its default), and images with loading=lazy and decoding=async keeping stored dimensions and alt=\"\" (D-82, D-83, D-71, 2026-10-28 layer options: links_new_tab; CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8)"
- "Journal pages render <SeoBreadcrumbs> at the top of their content by default; journal.breadcrumbs false turns that off for sites whose layout places breadcrumbs, while the BreadcrumbList data is unchanged (D-86, 2026-10-28 layer options: journal.breadcrumbs)"
- "JournalPagination renders <SeoPagination class-prefix=\"journal-pagination\"> with the journal's labels, so existing journal-pagination class names and app CSS keep working while links follow the CMS page form (path-form routes are registered when the build option is path); category pillar content renders on page 1 only, the short description on every page (D-40, D-41, D-42, UI-SPEC L2)"
- "Listing pages (index, category, tag, author) add an ItemList node #itemlist with their posts through useSchemaNode, and FAQ blocks add a FAQPage node (schema prop, default true), so a page still has exactly one JSON-LD script with one @graph (D-43)"
- "The site-cache purge files of the journal, menu and FAQ layers are removed; the one route lives in the SEO layer, and the journal's deprecated /api/__journal__/cache/purge keeps working through the SEO layer's purge library (RESEARCH Pitfall 6)"
- "A playground extending journal, menu and FAQ layers renders each page with one resolve call, one canonical and one JSON-LD script"
- "Journal breadcrumbs turned off (journal.breadcrumbs false): journal pages render no <SeoBreadcrumbs>, a layout instance renders the trail, JSON-LD unchanged (UI-SPEC partial: Journal breadcrumbs turned off)"
- {statement: "Post card: each .journal-post-card contains exactly one <a, the title is h2 (h3 inside related posts) and no journal-post-card__more exists (UI-SPEC L5 backstop)", verification: backstop}
- {statement: "Heading convention over the fixture pages: exactly one <h1, no skipped level, FAQ headingLevel 0 renders <p>; the post page in both Journal H1 modes (on: title h1, body h1 demoted; off: title p.journal-post__title, the body's single h1 is the page h1); postTitleTag 'h1' with the setting off gives two <h1 and 'p' with it on gives none, asserted as the documented consequence of the override (UI-SPEC L6 backstop)", verification: backstop}
- {statement: "Outbound links with outbound.new_tab false keep their editor rel and domain-list nofollow/sponsored and have no target, no noopener and no .seo-visually-hidden hint; with true and the default seo.links.newTabHint they have all three, and with newTabHint false they have target and noopener without the hint (UI-SPEC L7 backstop, CONTEXT 2026-10-28 accepted defaults)", verification: backstop}
- "Journal related posts stay as today by default (same category, three, newest first); with golem15.journal.related_mode scored they are 3 to 6 (golem15.journal.related_count) ranked by shared tags and categories, and the post answer adds previous_in_category and next_in_category, which the journal layer renders as server-rendered links instead of the site-wide neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: related posts opt-in in Journal, current behaviour as default; porting contract kept)"
prohibitions:
- requirement_id: "D-83"
category: values
status: resolved
verification: test
statement: "Internal links never get rel or target from the outbound transform, and auto-links are plain <a href> with no class or rel"
- requirement_id: "D-82"
category: safety
status: resolved
verification: test
statement: "Auto-linking never injects markup inside an attribute, a heading, an existing link, code, pre, figcaption or button, and never links a page to itself"
artifacts:
- path: "../vue-seo-layer/app/utils/bodyLinks.ts"
provides: "autolink, applyOutbound, imageAttrs (pure)"
contains: "export function autolink"
- path: "../vue-journal-layer/app/utils/htmlGate.ts"
provides: "gate with demoteH1 and the L7 pipeline"
contains: "demoteH1"
- path: "../vue-journal-layer/app/components/Journal/PostCard.vue"
provides: "L5 one anchor, headingLevel"
contains: "headingLevel"
- path: "../vue-faq-layer/app/utils/faqSchema.ts"
provides: "FAQPage node from FAQ items"
contains: "FAQPage"
key_links:
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
to: "../vue-seo-layer/app/utils/bodyLinks.ts"
via: "the gate passes sanitized HTML through autolink and applyOutbound with the resolve answer's autolinks and outbound"
pattern: "applyOutbound|autolink"
- from: "../vue-journal-layer/app/journal-pages/PostPage.vue"
to: "Journal API content_h1_to_h2"
via: "titleTag and demoteH1 follow post.content_h1_to_h2 unless postTitleTag overrides the title"
pattern: "content_h1_to_h2"
- from: "../vue-journal-layer/nuxt.config.ts"
to: "../vue-seo-layer/nuxt.config.ts"
via: "extends: ['vue-seo-layer']"
pattern: "vue-seo-layer"
---
<!-- planner-discipline-allow: @nuxtjs/seo -->
<!-- planner-discipline-allow: journal-post-card__more -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan moves the journal, menu and FAQ layers onto the shared SEO layer: CMS head and one graph, the heading convention in both Journal H1 modes, one link per card, body links and images by the rules, breadcrumbs and pagination from the CMS, and one purge route. It also adds Journal's opt-in scored related posts and category neighbours.
<objective>
Change the three existing layers (each main, not pushed; tags in 33-14) and add the body-link utilities to vue-seo-layer:
- journal: drop @nuxtjs/seo, extend the SEO layer, L5 cards, L6 headings, L7 body pipeline, postTitleTag and journal.breadcrumbs options, SeoPagination wrapper, pillar on page 1, ItemList nodes (2026-10-28 layer options; D-50, D-52, D-80..83, D-71, D-86, D-40..42);
- menu and FAQ: extend the SEO layer, remove purge copies, FAQ headingLevel 0 and the FAQPage node (D-52, D-43, D-71);
- vue-seo-layer: `app/utils/bodyLinks.ts` (autolink, applyOutbound, imageAttrs);
- sm-journal-plugin and the journal layer: opt-in scored related posts and category neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: opt-in, current behaviour default). This task moved here from 33-11 in the 2026-10-28 revision so that 33-11 stays within its task budget; it changes the same PostPage.vue this plan already touches.
Purpose: SC-2 in the layers every site uses; the related-posts part of D-82.
Output: commits in ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer and ../sm-journal-plugin (main, not pushed; tags in 33-14).
**Planner decisions recorded here:**
- **P33-33 (dependency during development):** each layer's package.json gets `"vue-seo-layer": "link:../vue-seo-layer"` in dependencies with the lockfile updated by `pnpm install`; 33-14 replaces it with the git tag pin (`git+https://git.golem15.com/golem15/vue-seo-layer.git#v<tag>`) before tagging (2026-10-28 release cadence: all tags in 33-14).
- **P33-34 (fallback meta):** app/utils/seo.ts keeps only the fallback (post title, excerpt, featured image) applied when `useCmsSeo` has no answer (`seo.cms` false, as summercms.io in 33-12, or the CMS unreachable), so a site without the SEO plugin keeps titles and no page gets two titles.
- **P33-35 (H1 flag source):** the post page reads `post.content_h1_to_h2` (33-02's additive key in the post Show answer); when the key is absent (an older Journal) it behaves as true, the D-50 default.
- **P33-36 (override cases in smoke):** postTitleTag is copied into `runtimeConfig.public.journal.postTitleTag`, so the smoke runs the override cases on the same build with `--env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1` (and `p`) and a second expectation file.
- **P33-44 (related options, moved from 33-11; CONTEXT 2026-10-28 accepted defaults: related posts opt-in, current behaviour default):** config keys `golem15.journal.related_mode` (category | scored, default category) and `golem15.journal.related_count` (3 to 6, default 3); the existing previous_post/next_post keys keep their meaning; previous_in_category and next_in_category (primary = first category) are added only in scored mode.
- **P33-35 also covers category pillar text:** CategoryPage and CategoryHeader sanitize the pillar text with `demoteH1 = category.content_h1_to_h2 !== false` (33-02's additive key on category rows; absent behaves as true), so pillar text follows the Journal setting like post bodies (CONTEXT 2026-10-28 H1 conversion scope).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/33-seo-output-audit-and-content/33-08-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-02-SUMMARY.md
@CLAUDE.md
<interfaces>
From 33-07/33-08 (vue-seo-layer): extends target `vue-seo-layer`; useCmsSeo answer in useState; useSchemaNode(node); <SeoBreadcrumbs> (props items, label, hideCurrent; page meta seo.breadcrumbs), <SeoPagination> (props current, last, labels, classPrefix), usePagination({current, last}), useSeoLabels, `seo-visually-hidden` class, runtimeConfig cachePurge/siteCache keys, server/lib/siteCachePurge.ts, smoke-ssr count=/header=.
Resolve answer (P33-26): autolinks [{phrase, url}], autolink_max, outbound {new_tab, nofollow_domains, sponsored_domains}, breadcrumbs, pagination, url (absolute page URL, its path is "self" for auto-links).
Journal API (33-02): post Show and every category row of GET /categories have content_h1_to_h2 (boolean); featured_images[{id, url, file_name, file_size}].
sm-journal-plugin (for Task 4): controllers/api/posts.go Show (related_posts via relatedPosts, previous_post/next_post via siblingPost), posts_helpers.go (relatedPosts 152-172, siblingPost 122-148), config/config.yaml (33-02); vue-journal-layer PostPage.vue siblings nav (51-66), utils/types.ts (JournalPostSibling).
vue-journal-layer today: lib/journal-module.ts (defaults routes, perPage, sort, relatedCount, rss → runtimeConfig.public.journal), app/utils/htmlGate.ts (gateJournalHtml with sanitize-html POLICY and KEEP_ALL), PostCard.vue:15-24 (image link + read-more), PostPage.vue:14 (cfg), :40 (useSeoMeta postSeoMeta), :46 (h1), Pagination.vue, utils/pagination.ts (paginationWindow), CategoryHeader.vue, RelatedPosts.vue, nuxt.config.ts (@nuxtjs/seo, ogImage, seo, sitemap keys), testing/smoke-ssr.mjs and expect-acme.txt.
vue-faq-layer Block.vue (headingLevel 2..4, aria-labelledby), useFaq answer {name, description, content[{question, answer}]}.
RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text nodes with an open-tag stack).
</interfaces>
</context>
## Execution units and resume points
- Commit groups per repository, staged by path (`git -C ../<layer> add <paths>`, deletions with `git rm`). `npm --prefix ../<layer> run test` green at every commit in that layer; in ../sm-journal-plugin `go vet ./...` and `go test -short ./...` green at every commit, and its untracked `go.work.sum` (another session's) stays alone.
- Resume check: `git -C ../<repo> log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | journal: `feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)` | expect-acme.txt tracer lines pass |
| 2 | seo: `feat: body link utilities (auto-links, outbound rules, image attributes)`; journal: A `feat: body links, outbound rules and image attributes in the post body`; B `feat!: one link per post card and the heading convention`; C `feat: breadcrumbs, CMS pagination and pillar content on page one`; D `feat: ItemList nodes on listing pages` | both expectation files pass |
| 3 | menu: `feat!: extend the SEO layer and drop the copied purge route`; faq: A `feat!: extend the SEO layer and drop the copied purge route`; B `feat: FAQPage node and widget headings`; journal: `docs: README for the SEO layer` | menu and FAQ smokes pass |
| 4 | journal plugin: `feat: opt-in scored related posts and category neighbours`; journal layer: `feat: category neighbours on the post page` | plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "a Journal post page renders through the shared SEO layer with exactly one H1 in both Journal H1 modes, one canonical and one JSON-LD script" — one path only</name>
<files>../vue-journal-layer/package.json, ../vue-journal-layer/pnpm-lock.yaml, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/server/api/__sitemap__/journal.ts, ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/plugins/site-cache-purge.ts, ../vue-journal-layer/tests/seo.test.ts, ../vue-journal-layer/tests/sitemap.test.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json, ../vue-journal-layer/testing/fixtures/acme/seo/site.json</files>
<read_first>
- ../vue-journal-layer/nuxt.config.ts, package.json, lib/journal-module.ts, app/journal-pages/PostPage.vue, app/components/Journal/PostBody.vue, app/utils/{htmlGate,seo,sitemap,types}.ts, server/api/__journal__/cache/purge.post.ts
- ../vue-seo-layer/nuxt.config.ts, app/composables/useCmsSeo.ts, testing/smoke-ssr.mjs (count=, header=)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L6 table and the post-title CSS, L7 step 1)
</read_first>
<action>
Per D-97, D-50, the 2026-10-28 Journal H1 setting and layer options, P33-33 to P33-36: package.json removes @nuxtjs/seo and adds the link dependency; nuxt.config.ts extends vue-seo-layer, drops @nuxtjs/seo from modules and the ogImage, seo and sitemap keys and the cachePurge/siteCache runtime keys (now in the SEO layer), keeps journal.cachePurgeToken and the /api/__journal__/** route rule. Remove the journal sitemap source (server/api/__sitemap__/journal.ts, app/utils/sitemap.ts, tests/sitemap.test.ts) and the copied shared purge files (server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts); the deprecated /api/__journal__/cache/purge imports the purge library from vue-seo-layer. journal-module.ts adds `postTitleTag: 'auto'` and `breadcrumbs: true` to defaults and runtimeConfig.public.journal; types.ts adds them and the post's `content_h1_to_h2?: boolean`. PostPage.vue: titleTag = postTitleTag when 'h1' or 'p', else 'h1' when content_h1_to_h2 !== false, else 'p'; fallback meta only when no CMS answer (P33-34). htmlGate.ts: `gateJournalHtml(html, {demoteH1})` applying sanitize-html transformTags h1→h2 only when demoteH1; PostBody passes `demoteH1: post.content_h1_to_h2 !== false`. journal-tokens.css gets --journal-post-title-size 2em, --journal-post-title-weight 700, --journal-post-title-line-height 1.2; journal-base.css styles .journal-post__title with them and --journal-font-heading. smoke-ssr.mjs copies the SEO layer's count= and header= support (apps run it from node_modules). fixture-api.mjs serves the SEO fixtures; posts.json gets a post with content_h1_to_h2 true and one with false, each with one body h1 and one h2. expect-acme.txt tracer lines: for the "on" post `count=<h1:1 contains=<h1 class="journal-post__title"`, for the "off" post `count=<h1:1 contains=<p class="journal-post__title"`, both `count=application/ld+json:1 count=rel="canonical":1`. htmlGate.test.ts covers demoteH1 true and false. Commit as listed (one feat! commit: the layer drops @nuxtjs/seo).
</action>
<verify>
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -10</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when>
<automated>npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -3 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -20</automated>
<fails_when>non-zero exit from the build or the smoke, or a "FAIL" line in the smoke output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "@nuxtjs/seo" ../vue-journal-layer/package.json ../vue-journal-layer/nuxt.config.ts` prints nothing
- `grep -n "vue-seo-layer" ../vue-journal-layer/package.json ../vue-journal-layer/nuxt.config.ts` prints a line in each
- `git -C ../vue-journal-layer ls-files server/lib server/api/__site__ server/api/__sitemap__` prints nothing
- `grep -n "postTitleTag" ../vue-journal-layer/lib/journal-module.ts ../vue-journal-layer/app/journal-pages/PostPage.vue` prints a line in each
</acceptance_criteria>
<done>A Journal post renders through the SEO layer with exactly one H1 whichever Journal H1 mode the site uses.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Body link utilities and the L7 pipeline, L5 post cards, L6 headings, breadcrumbs, CMS pagination, pillar on page 1 and ItemList nodes</name>
<files>../vue-seo-layer/app/utils/bodyLinks.ts, ../vue-seo-layer/tests/bodyLinks.test.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/expect-acme-title-h1.txt, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/app/utils/types.ts</files>
<read_first>
- ../vue-journal-layer/app/components/Journal/{PostCard,Pagination,CategoryHeader,RelatedPosts,FeaturedImage}.vue and the journal pages
- ../vue-journal-layer/app/utils/pagination.ts and routes.ts
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Code Examples: phrase auto-linking; Pitfall 7)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L5, L6, L7, L1 journal placement switch, L2 Journal note)
</read_first>
<behavior>
- autolink: first occurrence only, at most max per text, case-insensitive whole words, never inside a, h1–h6, code, pre, figcaption, button, never to the page's own path, phrase with regex characters matched literally, URLs escaped in href; output is a plain <a href>
- applyOutbound: an https link to another host keeps its rel tokens, gains nofollow for a listed domain (subdomains included) or sponsored, and with newTab true gains target=_blank, rel noopener and the hidden hint span (hint default on, per CONTEXT 2026-10-28 accepted defaults; with hint false target and noopener only); with newTab false none of the three; internal and relative links unchanged
- Category pillar text: a category row with content_h1_to_h2 true (or the key absent) renders its pillar h1 as h2 on the category page; with false the h1 stays (fixture categories.json has one of each)
- imageAttrs: body img gains loading=lazy and decoding=async, keeps width/height and alt=""
- Smoke: each .journal-post-card has one <a and an h2 title, related posts are h2 with h3 cards, no read-more link; listing pages have one h1 and on ?page=2 the · Strona 2 suffix; the post page renders one nav.seo-breadcrumbs (none with journal.breadcrumbs false through NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false); the category page shows its pillar content on page 1 only; journal-pagination markup stays with first/last and gap items; index and category pages have one JSON-LD script containing ItemList
- Smoke with NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 (expect-acme-title-h1.txt): the "off" post has two <h1 (documented override); with p, the "on" post has no <h1
</behavior>
<action>
Per D-80 to D-83, D-71, D-52, D-86, D-40 to D-42, D-43 and UI-SPEC L1/L2/L5/L6/L7: `vue-seo-layer/app/utils/bodyLinks.ts` (pure, no Vue imports, outside auto-import dirs' side effects) with `autolink(html, links, max, selfPath)`, `applyOutbound(html, {siteHost, newTab, nofollowDomains, sponsoredDomains, hint})`, `imageAttrs(html)`, working on sanitize-html's normalised output with a tag/text split and an open-tag stack (RESEARCH example), attribute values escaped; tests first. Journal htmlGate runs the L7 order after sanitizing (POLICY now allows rel tokens nofollow, sponsored, ugc on a, and target only as added by applyOutbound); PostBody passes the resolve answer's autolinks, autolink_max, outbound, url path and the SEO-layer option seo.links.newTabHint as hint (default true: CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8). CategoryPage and CategoryHeader sanitize the pillar text with demoteH1 from the category row's content_h1_to_h2 (P33-35; types.ts adds it to JournalCategory; categories.json gets one category per value, each with a pillar h1). PostCard.vue per L5 (headingLevel 2|3, one title link, image without link, stretched-link CSS in journal-base.css, focus ring with :has); RelatedPosts h2 and cards headingLevel 3; Pagination.vue wraps SeoPagination with class-prefix journal-pagination and the journal labels; journal-module.ts registers path-form listing routes (`<listing>/<segment>/:page`) when the SEO build option pagination.form is path (routes.ts helper, tested); CategoryHeader renders rich content only on page 1; journal pages render SeoBreadcrumbs when journal.breadcrumbs is true; listing pages call useSchemaNode with an ItemList (@id <url>#itemlist, ListItem position and url of each post on the page). Expectation files per behavior list. Commit groups as listed.
</action>
<verify>
<automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -8 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -8</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in either</fails_when>
<automated>npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -3 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -15 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme-title-h1.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 2>&1 | tail -8</automated>
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "export function autolink\|export function applyOutbound\|export function imageAttrs" ../vue-seo-layer/app/utils/bodyLinks.ts` prints three lines
- `grep -n "journal-post-card__more\|readMore" ../vue-journal-layer/app/components/Journal/PostCard.vue` prints nothing
- `grep -n "SeoPagination" ../vue-journal-layer/app/components/Journal/Pagination.vue` prints one line
- `grep -n "count=<h1:1" ../vue-journal-layer/testing/expect-acme.txt` prints at least five lines (every fixture page)
- `grep -n "content_h1_to_h2" ../vue-journal-layer/app/journal-pages/CategoryPage.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` prints at least one line
</acceptance_criteria>
<done>Journal pages follow the heading, card, body-link, image, breadcrumb and pagination rules, verified on built pages.</done>
</task>
<task type="auto">
<name>Task 3: Menu and FAQ layers on the SEO layer, FAQ widget headings and the FAQPage node, one-resolve check across all three layers</name>
<files>../vue-menu-layer/package.json, ../vue-menu-layer/pnpm-lock.yaml, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/plugins/site-cache-purge.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/README.md, ../vue-faq-layer/package.json, ../vue-faq-layer/pnpm-lock.yaml, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/app/components/Faq/Block.vue, ../vue-faq-layer/app/utils/faqSchema.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/plugins/site-cache-purge.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/tests/faqSchema.test.ts, ../vue-faq-layer/testing/expect.txt, ../vue-faq-layer/README.md, ../vue-journal-layer/README.md, ../vue-seo-layer/README.md</files>
<read_first>
- ../vue-menu-layer/nuxt.config.ts, package.json, testing/smoke-ssr.mjs, README.md
- ../vue-faq-layer/nuxt.config.ts, app/components/Faq/{Block,List,Item}.vue, app/composables/useFaq.ts, testing/expect.txt, README.md
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (L6 FAQ row)
</read_first>
<action>
Per D-52, D-43, D-71, D-97 and P33-33: menu and FAQ layers add the link dependency, extend vue-seo-layer, drop their cachePurge/siteCache runtime keys and their copies of the purge route, library, plugin and test (git rm); their smoke tests keep passing (the SEO middleware fails open against their fixture APIs). FAQ Block.vue accepts `headingLevel: 0 | 2 | 3 | 4`, rendering `<p class="faq-block__title">` with the same id and aria-labelledby at 0; new prop `schema` (default true) calls useSchemaNode with `faqSchema(faq)` — FAQPage @id <url>#faq, mainEntity Question/acceptedAnswer with answer text stripped of markup (pure util, tested). expect.txt asserts the 0 level and that the answers are in the HTML inside details. Then check across the three layers: in a temporary directory outside the repositories, an app extending vue-journal-layer, vue-menu-layer and vue-faq-layer (link dependencies) renders the journal acme fixtures with the journal smoke harness and an expectation that the fixture API recorded exactly one resolve call per page render and that each page has one canonical and one ld+json; record the command and its output in the SUMMARY (no files committed for the scratch app). README updates in each layer: extending the SEO layer, the removed purge files, new options and props. Commit groups as listed.
</action>
<verify>
<automated>npm --prefix ../vue-menu-layer run test 2>&1 | tail -6 && npm --prefix ../vue-faq-layer run test 2>&1 | tail -6</automated>
<fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line in either</fails_when>
<automated>npm --prefix ../vue-menu-layer run build:playground 2>&1 | tail -2 && npm --prefix ../vue-menu-layer run smoke:ssr 2>&1 | tail -6 && npm --prefix ../vue-faq-layer run build:playground 2>&1 | tail -2 && npm --prefix ../vue-faq-layer run smoke:ssr 2>&1 | tail -6</automated>
<fails_when>non-zero exit from any command, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../vue-menu-layer ls-files server/lib server/api/__site__` and `git -C ../vue-faq-layer ls-files server/lib server/api/__site__` print nothing
- `grep -n "vue-seo-layer" ../vue-menu-layer/nuxt.config.ts ../vue-faq-layer/nuxt.config.ts` prints a line in each
- `grep -n "FAQPage" ../vue-faq-layer/app/utils/faqSchema.ts` prints at least one line
- the SUMMARY records the cross-layer check with one resolve call per page
</acceptance_criteria>
<done>All three content layers render through the SEO layer with one graph, one canonical and one purge route.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Opt-in scored related posts and category neighbours (Journal API and layer)</name>
<files>../sm-journal-plugin/classes/related.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
<read_first>
- ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts 152-172, siblingPost 122-148) and posts.go Show (147-154)
- ../sm-journal-plugin/config/config.yaml (added by 33-02) and journal_api_matrix_test.go (the default answer that must not change)
- ../vue-journal-layer/app/journal-pages/PostPage.vue (siblings nav 51-66, as changed by Tasks 1 and 2) and app/utils/types.ts
</read_first>
<behavior>
- Default mode: Show answers exactly as before (journal_api_matrix_test.go unchanged)
- related_mode scored, related_count 5: up to 5 published posts other than the post, ranked by shared tags (2 points each) plus shared categories (1 point each), ties by newest; count outside 3–6 is clamped; previous_in_category and next_in_category are the neighbours by published_at within the post's first category
- The layer renders previous_in_category/next_in_category links (server-rendered <a href> with the title in the anchor text) when present, else the existing site-wide neighbours
</behavior>
<action>
Per D-82, P33-44 and the CONTEXT 2026-10-28 accepted default (related posts opt-in in Journal, current behaviour as default; the porting contract keeps the default answer byte for byte): `classes/related.go` with the scoring query (one SQL with counts, published only for non-editors) and the category neighbours; Show uses it only in scored mode and adds the two keys; config/config.yaml documents both keys; README section. Layer PostPage prefers the in-category neighbours; types.ts adds them; a fixture post with them and an expectation line. Tests first (`TestRelatedScored`, `TestRelatedDefaultUnchanged` in related_media_test.go, which 33-11 extends with TestImageVariants). Commit as listed.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestRelatedScored|TestRelatedDefaultUnchanged|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestRelatedScored missing from the "--- PASS" lines</fails_when>
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated>
<fails_when>non-zero exit from any command, or a "# fail" count above 0, a "not ok" line or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "golem15.journal.related_mode\|golem15.journal.related_count" ../sm-journal-plugin/classes/related.go ../sm-journal-plugin/config/config.yaml` prints at least two lines
- `grep -n "previous_in_category" ../vue-journal-layer/app/journal-pages/PostPage.vue` prints at least one line
- TestJournalAPIMatrix passes and `git -C ../sm-journal-plugin show --stat --format= HEAD` (the related-posts commit) does not list journal_api_matrix_test.go
</acceptance_criteria>
<done>Sites can switch on richer related posts and category neighbours without changing the default answer.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| CMS content (body HTML, phrases, URLs) → SSR HTML | stored content rendered to visitors |
| resolve answer → layer transforms | phrase table and domain lists |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-39 | Tampering (XSS) | autolink and outbound transforms | high | mitigate | run on sanitize-html output only; links inserted into text nodes only; href and rel built from validated values with attribute escaping; unit tests with quotes, <, javascript: URLs refused (only / paths and http(s)) |
| T-33-40 | Tampering | htmlGate policy (rel/target) | medium | mitigate | the sanitizer accepts only the rel tokens nofollow, sponsored, ugc; target and noopener are added after sanitizing by applyOutbound, never taken from stored HTML |
| T-33-41 | Information disclosure | removal of @nuxtjs/seo | low | mitigate | no site config with NUXT_SITE_* is published anymore; purge-token-check stays in the journal testing harness |
| T-33-42 | Denial of service | auto-link phrase table size | low | mitigate | at most autolink_max links per text; phrase scan is linear in text length per phrase; the resolver filters phrases per locale |
| T-33-70 | Information disclosure | scored related posts and category neighbours | medium | mitigate | the scoring and neighbour queries return published posts only for visitors (editors keep today's rule), TestRelatedScored plants a draft sharing every tag and asserts it never appears |
| T-33-SC | Tampering | npm installs | high | mitigate | @nuxtjs/seo removed, no package added; the only new dependency is the first-party vue-seo-layer through a local link; lockfiles committed |
</threat_model>
<verification>
- `npm --prefix ../<layer> run test` green in all four layers.
- Journal: playground build plus both expectation files; menu and FAQ: playground builds and smoke.
- Cross-layer check recorded in the SUMMARY.
- sm-journal-plugin: vet, `test -short`, TestRelatedScored, TestRelatedDefaultUnchanged and the API matrix.
</verification>
<success_criteria>
- The three content layers extend the SEO layer and drop their own SEO module and purge copies.
- Headings, cards, body links, images, breadcrumbs and pagination follow the rules on built pages in both Journal H1 modes, for post bodies and category pillar text.
- Scored related posts and category neighbours are available as an opt-in; the default answer is unchanged.
</success_criteria>
## Artifacts this phase produces
- vue-seo-layer: `app/utils/bodyLinks.ts` (`autolink`, `applyOutbound`, `imageAttrs`), `tests/bodyLinks.test.ts`.
- vue-journal-layer options: `journal.postTitleTag` ('auto' | 'h1' | 'p'), `journal.breadcrumbs` (boolean); runtime keys `runtimeConfig.public.journal.postTitleTag`, `.breadcrumbs`; env `NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG`, `NUXT_PUBLIC_JOURNAL_BREADCRUMBS`.
- vue-journal-layer: `gateJournalHtml(html, {demoteH1})`, PostCard prop `headingLevel`, CSS tokens `--journal-post-title-size`, `--journal-post-title-weight`, `--journal-post-title-line-height`, path-form pagination routes, `testing/expect-acme-title-h1.txt`, smoke-ssr `count=`/`header=`; removed: `server/api/__sitemap__/journal.ts`, `app/utils/sitemap.ts`, the shared purge copies, the @nuxtjs/seo dependency.
- vue-faq-layer: Block props `headingLevel: 0` and `schema`, `app/utils/faqSchema.ts`.
- vue-menu-layer: extends vue-seo-layer; purge copies removed.
- sm-journal-plugin: config keys `golem15.journal.related_mode`, `golem15.journal.related_count`; `classes/related.go`; API keys `previous_in_category`, `next_in_category` (scored mode only); tests `TestRelatedScored`, `TestRelatedDefaultUnchanged` in `related_media_test.go`.
- vue-journal-layer: category neighbours on the post page; `JournalCategory.content_h1_to_h2` and the pillar-text demotion.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,434 @@
---
phase: 33-seo-output-audit-and-content
plan: 10
type: execute
wave: 6
depends_on: ["33-06"]
files_modified:
- ../sm-seo-plugin/classes/crawl/crawler.go
- ../sm-seo-plugin/classes/crawl/fetch.go
- ../sm-seo-plugin/classes/crawl/parse.go
- ../sm-seo-plugin/classes/crawl/checks.go
- ../sm-seo-plugin/classes/crawl/links.go
- ../sm-seo-plugin/classes/crawl/orphans.go
- ../sm-seo-plugin/classes/crawl/store.go
- ../sm-seo-plugin/classes/crawl/fix.go
- ../sm-seo-plugin/classes/crawl/render.go
- ../sm-seo-plugin/classes/crawl/render_chrome.go
- ../sm-seo-plugin/classes/crawl/compare.go
- ../sm-seo-plugin/classes/issues.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/models/crawl.go
- ../sm-seo-plugin/models/crawl/columns.yaml
- ../sm-seo-plugin/models/crawl/fields.yaml
- ../sm-seo-plugin/models/crawl_page.go
- ../sm-seo-plugin/models/issue.go
- ../sm-seo-plugin/models/issue/columns.yaml
- ../sm-seo-plugin/models/issue/fields.yaml
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/models/registry.go
- ../sm-seo-plugin/updates/202610280005_create_golem15_seo_crawls.go
- ../sm-seo-plugin/updates/202610280006_add_golem15_seo_audit_settings.go
- ../sm-seo-plugin/updates/registry.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/issues.go
- ../sm-seo-plugin/controllers/issues/config_list.yaml
- ../sm-seo-plugin/controllers/issues/config_form.yaml
- ../sm-seo-plugin/controllers/issues/config_filter.yaml
- ../sm-seo-plugin/controllers/issues/_issue_details.htm
- ../sm-seo-plugin/controllers/crawls.go
- ../sm-seo-plugin/controllers/crawls/config_list.yaml
- ../sm-seo-plugin/controllers/crawls/config_form.yaml
- ../sm-seo-plugin/controllers/crawls/config_filter.yaml
- ../sm-seo-plugin/controllers/crawls/_crawl_summary.htm
- ../sm-seo-plugin/controllers/api/crawls.go
- ../sm-seo-plugin/controllers/api/issues.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/jobs.go
- ../sm-seo-plugin/schedule.go
- ../sm-seo-plugin/commands.go
- ../sm-seo-plugin/menu_item_types.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/config/config.yaml
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.yaml
- ../sm-seo-plugin/README.md
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- ../sm-seo-plugin/crawler_test.go
- ../sm-seo-plugin/crawl_site_test.go
- ../sm-seo-plugin/crawl_render_test.go
- .planning/notes/2026-10-28-chromedp-rendered-crawl.md
- .planning/research/STACK.md
autonomous: true
requirements: [SC-3, D-14, D-46, D-51, D-70, D-71, D-80, D-81, D-82, D-85, D-24, D-50, D-10, D-94]
estimate:
tokens: 215000
raw_tokens: 215000
tasks: 4
confidence: low
must_haves:
truths:
- "A built-in crawler runs as a conga job (on demand from SEO › Crawls or Site audit, from MCP, or on the crawl_schedule setting off/daily/weekly at night): it fetches the site's own pages over HTTP from the home page, the sitemap index and its files, and same-host links, with at most two requests at a time, crawl_delay between requests (default 250 ms), a 10 s timeout, a 5 MiB body cap and crawl_max_pages (default 5000); one crawl runs at a time and older crawls beyond crawl_keep are deleted with their pages and issues (D-14)"
- "Outbound requests go through a fetchguard client limited to the site's host (and its www twin), HTTPS only and without following redirects; plain http (staging, local) works only when the config key golem15.seo.crawl_trusted is true (D-14, security)"
- "The crawler reports per URL every crawl kind of UI-SPEC C23: page_status, sitemap_error, sitemap_noindex, sitemap_blocked, sitemap_missing, broken_link, redirected_link, canonical_missing, canonical_error, missing/duplicate title and description, h1_missing, h1_multiple, heading_skipped, heading_empty, h1_title_mismatch, jsonld_invalid, jsonld_multiple, jsonld_required, jsonld_recommended, hreflang_reciprocal, hreflang_error, generic_anchor, image_alt, image_size, featured_lazy, content_js and orphan_page, with the severities of the kinds table (D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24)"
- "The raw-HTML checks always run: the server-rendered HTML fetched without JavaScript is checked against CMS expectations (the descriptor title must appear in the H1, the word count must reach half of the descriptor's Words, the CMS-known links of the page must be present); a miss is content_js ('Content needs JavaScript') with details.check cms (D-70, D-71; CONTEXT 2026-10-28 rendered comparison: raw-HTML checks always run)"
- "When the config key golem15.seo.crawl_chrome_path names a Chrome or Chromium binary, the crawler also renders up to golem15.seo.crawl_render_max pages (default 200, in crawl order) in headless Chrome through github.com/chromedp/chromedp and compares each with its raw HTML: same-host links, an H1 or a substantial amount of text that appear only after JavaScript are reported as content_js with details.check rendered and details.what naming them; without a Chrome path the crawl records render_status skipped, and P6, GET /_seo/api/v1/crawls/{id} and seo_get_crawl say that the comparison was skipped; a renderer failure records render_status failed with its note and never fails the crawl (D-70; CONTEXT 2026-10-28 rendered comparison: optional chromedp; P33-55)"
- "The headless browser is held to the crawler's fetchguard limits: Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private addresses refused unless golem15.seo.crawl_trusted is true) while every other host resolves to NOTFOUND; every request is intercepted and failed unless it is https (http only in trusted mode) to an allowed host; each crawl uses a fresh temporary profile deleted afterwards, with no cookies, credentials or extensions and downloads denied; each page has a 20 s timeout and a 5 MiB DOM cap, one tab renders at a time, and Chrome is closed when the crawl finishes, fails or is cancelled (D-14 security, P33-55)"
- "CMS-data checks run at the end of every crawl (and when the list is opened before any crawl) and are stored with the crawl's issues under source cms; they now include image_alt from the descriptors' images (empty alt that is not decorative) and h1_in_content, which runs only for records whose descriptor sets TopHeading 2 (Journal posts and, since the 2026-10-28 H1 conversion scope, categories' pillar text), so it is off when Journal's H1 setting is off (D-85, D-50, 2026-10-28 Journal H1 setting)"
- "Every data issue names the MCP tool that fixes it (seo_ tools for SEO data, the content type's ContentDescriptor.Tools for content: edit, headings, image_alt), and an issue caused by the site's template code carries fix code instead (UI-SPEC C23); no issue has neither (SC-3, D-10; CONTEXT 2026-10-28 success criterion 3 reading)"
- "orphan_page lists indexable sitemap pages with no inbound internal link in the crawl, with suggestions of pages whose text mentions the page's title or focus keyphrase (D-82)"
- "SEO › Site audit lists the stored issues (errors first) with the C23 columns, filters (status defaults to Open), the P7 preview (what was found, how to fix, page and Rich Results links), and the record actions Check this address again, Ignore and Stop ignoring plus bulk Ignore; ignored issues stay ignored in later crawls (UI-SPEC C23)"
- "SEO › Crawls lists runs with the C24 columns, the P6 preview (one status callout, stats strip, per-kind table linking to the filtered audit), Start crawl (toolbar, also on Site audit) refusing with crawls.already_running or crawls.no_site_url, Cancel on a queued or running crawl that keeps what was fetched, and delete refusing a running crawl with crawls.delete_running (UI-SPEC C24)"
- "The SEO settings get the Site audit tab: crawl_schedule, crawl_max_pages (10–50 000), crawl_delay (100–5000 ms), crawl_keep (1–50) and generic_anchors seeded with the en and pl lists (UI-SPEC C20, Q-1)"
- "MCP has seo_start_crawl, seo_cancel_crawl, seo_list_crawls, seo_get_crawl, seo_delete_crawls, seo_list_issues (filters severity, kind, source, fix, status, url, crawl), seo_recheck_url, seo_ignore_issues and seo_unignore_issues; TestEveryAdminOperationHasTool maps every Site audit and Crawls operation (D-10)"
- "On a non-production environment noindex and robots caused by the staging lock are never reported, and P6 shows crawls.staging_text (RESEARCH Pitfall 12, UI-SPEC partial: non-production)"
- "The existing GET /_seo/api/v1/issues answer keeps its Phase 32 keys and gains id, severity, source, fix, found_at, ignored and crawl_id"
- "Site audit and Crawls show their empty-state copy issues.list_empty and crawls.list_empty (UI-SPEC empty)"
- "Lists, forms and previews show the existing skeletons while loading; a running or queued crawl shows its P6 callout and asks to reload, with no polling (UI-SPEC loading, Q-5)"
- "A refused start shows the action-error toast; a failed crawl shows the P6 danger callout with the error and keeps the pages and issues fetched before the failure (UI-SPEC error)"
- "The audit list puts errors first with severity, kind and summary columns and hides ignored rows by default; the crawl preview shows its status callout, stats strip and per-kind table with filter links (UI-SPEC populated)"
- "Plural copy uses CLDR maps for rechecked, every ignore and delete confirm, and the other counted phrases of this phase (jsonValid, outlineCount, keyphrases_added, h1_converted in their plans) (UI-SPEC zero-one-many)"
- "Issue and crawl lists use the existing pagination and .summer-table-wrap scrolling; P7 lists at most 10 linking pages then issues.and_more; long URLs wrap and are set in DM Mono in P6, P7 (UI-SPEC overflow, long-text)"
- {statement: "A crawl of the planted test site finds each planted issue exactly once and no issue on its clean control page", verification: backstop}
prohibitions:
- requirement_id: "D-14"
category: safety
status: resolved
verification: test
statement: "The crawler never requests a host other than the site's own (no redirect following, no third-party links, no private address unless trusted mode is configured in Go config)"
- requirement_id: "D-14"
category: safety
status: resolved
verification: test
statement: "The crawler never changes data: it reads pages and writes only its own crawl and issue tables"
- requirement_id: "D-70"
category: safety
status: resolved
verification: test
statement: "The headless browser never loads a URL outside the site's host and its www twin, never reaches a private address unless trusted mode is configured in Go config, and never carries cookies or credentials; its executable path comes only from Go config, never from an admin setting or MCP"
artifacts:
- path: "../sm-seo-plugin/classes/crawl/crawler.go"
provides: "the crawl job: frontier, politeness, limits, cancel"
contains: "fetchguard"
- path: "../sm-seo-plugin/classes/crawl/checks.go"
provides: "per-page and site-wide checks"
contains: "h1_multiple"
- path: "../sm-seo-plugin/controllers/issues/_issue_details.htm"
provides: "P7"
contains: "summer-callout"
- path: "../sm-seo-plugin/crawler_test.go"
provides: "TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlTools, TestCrawlSchedule"
contains: "func TestCrawlerTracer("
- path: "../sm-seo-plugin/classes/crawl/compare.go"
provides: "compareRendered: raw versus rendered page facts (pure)"
contains: "func compareRendered("
- path: "../sm-seo-plugin/classes/crawl/render_chrome.go"
provides: "the chromedp-backed Renderer with the request guard"
contains: "chromedp"
- path: ".planning/notes/2026-10-28-chromedp-rendered-crawl.md"
provides: "decision note naming github.com/chromedp/chromedp, committed before the go.mod change"
contains: "github.com/chromedp/chromedp"
key_links:
- from: "../sm-seo-plugin/classes/crawl/crawler.go"
to: "summercms.go/modules/fetchguard"
via: "a fetchguard.Client with AllowHostsMode on the site host (TrustedMode only from config)"
pattern: "fetchguard\\.NewClient"
- from: "../sm-seo-plugin/classes/crawl/checks.go"
to: "../sm-seo-plugin/classes/schema/validate.go"
via: "rendered JSON-LD validated with the same rules as E9"
pattern: "schema\\.Validate"
- from: "../sm-seo-plugin/classes/crawl/fix.go"
to: "summercms.go/modules/pact/content.go"
via: "content issues name ContentDescriptor.Tools[edit|headings|image_alt]"
pattern: "Tools\\["
- from: "../sm-seo-plugin/classes/crawl/render_chrome.go"
to: "summercms.go/modules/fetchguard"
via: "the site host and its www twin are resolved and checked with fetchguard.IsPrivateAddr before Chrome starts; the checked addresses go into --host-resolver-rules"
pattern: "fetchguard\\.IsPrivateAddr"
- from: "../sm-seo-plugin/classes/crawl/crawler.go"
to: "../sm-seo-plugin/classes/crawl/compare.go"
via: "after a page's raw fetch, a configured Renderer renders it and compareRendered turns the differences into content_js findings"
pattern: "compareRendered"
---
<!-- planner-discipline-allow: seo -->
<!-- planner-discipline-allow: crawl_chrome_path -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan adds the audit: a built-in crawler that fetches the rendered site like a search engine, compares the raw HTML with the JavaScript-rendered page when a Chrome binary is configured, stores what it finds per URL with the CMS-data checks, and offers every issue to an MCP client with the tool that fixes it.
<objective>
Extend ../sm-seo-plugin (main, not pushed):
- the crawler as a conga job with storage, politeness limits and the SSRF guard (D-14);
- the checks of UI-SPEC C23 (D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24) and the merged CMS issues with fix routes (SC-3, CONTEXT 2026-10-28 success criterion 3 reading);
- the raw-versus-rendered comparison of D-70 through optional chromedp (CONTEXT 2026-10-28 rendered comparison), after its decision note and the legitimacy answer of 33-06 Task 4;
- Site audit and Crawls screens (C23, C24), the Site audit settings tab (C20), the nightly schedule;
- MCP tools for crawls and issues (D-10).
Purpose: ROADMAP success criterion 3 ("The crawler reports SEO issues per URL, and each issue can be fixed through MCP") and D-70's raw versus rendered check.
Output: commits in ../sm-seo-plugin; the chromedp decision note and STACK row as a planning-docs commit here before sm-seo-plugin's go.mod changes.
**Planner decisions recorded here:**
- **P33-37 (tables):** golem15_seo_crawls (id, status queued|running|finished|failed|cancelled, trigger admin|schedule|mcp, trigger_name, site, max_pages, pages, limit_hit, error, render_status skipped|done|failed (default skipped), render_pages (default 0), render_note, job_id, started_at, finished_at, created_at); golem15_seo_crawl_pages (crawl_id, url, status, location, content_type, title, description, canonical, robots, x_robots, h1 jsonb, headings jsonb, words, text (the page's main text as plain text, capped at 100 000 characters, read by orphan suggestions here and by E10 in 33-11), depth, in_sitemap, sitemap_file, links_out, links_in, fetched_at); golem15_seo_crawl_links (crawl_id, from_url, to_url, text, rel, internal); golem15_seo_issues (id, crawl_id null, source crawl|cms, kind, severity error|warning|notice, url, locale, details jsonb, tool, fix tool|code, fingerprint, found_at); golem15_seo_issue_ignores (fingerprint unique, admin_id, ignored_at). The fingerprint is kind + url + locale + the kind's identifying detail, so an ignore survives later crawls.
- **P33-38 (fix route):** `fix = tool` with the full tool name when a CMS value fixes it: SEO data → seo_set_url_seo, seo_update_robots, seo_create_redirect, seo_update_page; content → the record's ContentDescriptor.Tools (edit, headings, image_alt); otherwise `fix = code` with the template element named in details.what. A kind marked "MCP or Code" in C23 chooses per finding: content headings, links and JSON-LD inside the record's own content (descriptor Headings or body links, stored custom JSON-LD) are tool when the record's type names a fitting tool (H1 findings prefer Tools[headings], other content findings use Tools[edit]); elsewhere, and for a content finding whose type names no fitting tool (Journal categories name only headings, so a skipped level in pillar text stays as before), code. This is the CONTEXT 2026-10-28 success criterion 3 reading: data issues name a tool, template issues carry fix code.
- **P33-39 (one crawl at a time):** start refuses when a crawl is queued or running (row check in the start transaction with a row lock), since conga has no unique jobs.
- **P33-40 (schedule):** pact.HasSchedule entry `{Command: "seo:crawl", Args: ["--scheduled"], Cadence: pact.DailyAt(2, 30)}`; with --scheduled the command does nothing when crawl_schedule is off and runs weekly crawls only on Mondays in the app's location.
- **P33-41 (crawler identity):** User-Agent `SummerCMS-SEO-Crawler/1 (+<site_url>)`; robots.txt rules are not obeyed for the site's own audit (they are checked and reported instead).
- **P33-55 (rendered comparison, CONTEXT 2026-10-28 rendered comparison: optional chromedp):**
- Seam: `classes/crawl/render.go` defines `Renderer` (`Render(ctx, pageURL string) (string, error)`, `Close() error`); the crawl holds nil when the config key `golem15.seo.crawl_chrome_path` is empty and then records render_status skipped. The decision allows a setting or a config key; a config key (environment-overridable like every key) is used because an admin- or MCP-writable executable path would let any holder of golem15.seo.manage make the server run an arbitrary binary (T-33-69).
- Scope and limits: with a path, the crawler renders up to `golem15.seo.crawl_render_max` pages (default 200, the first in crawl order, so the home page and top-level pages come first) one tab at a time right after each page's raw fetch, 20 s per page, DOM read with chromedp's OuterHTML and capped at 5 MiB; the User-Agent is the P33-41 one.
- Comparison: `compareRendered(raw, rendered PageFacts) []Finding` in compare.go (pure, parse.go extracts the facts from both) reports content_js with details.check rendered and details.what naming same-host links present only after JavaScript (up to 5 paths), an H1 present only after JavaScript, or main text that grows by at least 50 words and 20 % after JavaScript. The raw checks against CMS expectations keep details.check cms; the fingerprint includes check, so the two never merge.
- Guard (render_chrome.go): before Chrome starts, the site host and its www twin are resolved and each address checked with fetchguard.IsPrivateAddr (refused unless golem15.seo.crawl_trusted); the checked addresses go into `--host-resolver-rules` as MAP lines after a leading `MAP * ~NOTFOUND`; Fetch-domain interception (`fetch.Enable`, `chromedp.ListenTarget` on `fetch.EventRequestPaused`) continues a request only when `allowRequest(url, hosts, trusted)` passes (https, http only in trusted mode, data: and blob: allowed, host in the list) and fails every other one with BlockedByClient; a fresh profile from os.MkdirTemp passed as the user data dir and removed in Close; downloads denied through `browser.SetDownloadBehavior`; chromedp's default allocator flags without additions that weaken the sandbox. Third-party scripts are therefore never loaded; the README and the note say so.
- Reporting: render_status done with render_pages, or failed with render_note (Chrome missing, a private address, a launch error); P6 shows one extra plain line like the staging one (`crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`); the crawl API and seo_get_crawl answer `render {status, pages, note}`.
- Copy (new keys, UI-SPEC C24 conventions): `crawls.render_skipped` EN "The comparison with the JavaScript-rendered pages was skipped: no Chrome path is configured (golem15.seo.crawl_chrome_path). The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript pominięto: nie skonfigurowano ścieżki do Chrome (golem15.seo.crawl_chrome_path). Sprawdzenia surowego HTML zostały wykonane."; `crawls.render_done` EN one ":count page was compared with its JavaScript-rendered version." other ":count pages were compared with their JavaScript-rendered versions." PL CLDR one/few/many/other "Porównano :count stronę/strony/stron/strony z wersją wyrenderowaną przez JavaScript."; `crawls.render_failed` EN "The comparison with the JavaScript-rendered pages stopped: :error. The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript zostało przerwane: :error. Sprawdzenia surowego HTML zostały wykonane."
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-05-SUMMARY.md
@CLAUDE.md
<interfaces>
fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, MaxBytes, Timeout}, cfg) / TrustedMode; Client.Get(ctx, url, header) → Result{StatusCode, Header, Body}; 3xx returned, never followed; WithTransport(ctx, rt) for tests; IsPrivateAddr(netip.Addr) bool (the dial guard's classification, for the Chrome resolver pinning).
chromedp (Task 4, version from 33-06-SUMMARY.md): NewExecAllocator(ctx, opts...) with DefaultExecAllocatorOptions, ExecPath, UserDataDir, Flag("host-resolver-rules", …), UserAgent; NewContext, Run, Navigate, WaitReady, OuterHTML, ListenTarget, Cancel; cdproto packages fetch (Enable with RequestPattern, EventRequestPaused, ContinueRequest, FailRequest), network (ErrorReasonBlockedByClient), browser (SetDownloadBehavior deny). Chromium 154 is installed on the development machine (RESEARCH Environment Availability); production servers have none unless an operator installs it.
golang.org/x/net/html (already in the dependency graph through sm-journal-plugin and the framework) for parsing; grobotstxt for robots checks (Phase 32).
conga: Manager.Dispatch(ctx, db, args, DispatchOpts{Label, Count}), StartJob, UpdateJobState, CheckIfCanceled, CancelJob; conga.JobID(ctx).
sm-seo-plugin: classes/issues.go (Issue{Kind, URL, Locale, Details, Tool}, CMS kinds), classes/schema (Build, Validate from 33-06), classes/sitemap_filter.go and contract.Listing (33-05), Service.Staging(), SiteURL(); controllers/notfound.go + config (record actions, bulk actions, row states pattern); controllers/history/_history_diff.htm (partial with summer-table); operations.go / operations_test.go; mcp.go.
pact.ContentDescriptor.Tools, ContentRecord.TopHeading, ContentLocale.Images/Headings (33-01).
Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details cells), C24, C20 Site audit tab, Destructive actions.
</interfaces>
</context>
## Execution units and resume points
- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group. Task 4's decision note and STACK row are a planning-docs commit here, staged by path, made before sm-seo-plugin's go.mod changes.
- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`; for the note, `git log --format=%s | grep -Fx 'docs(33-10): chromedp decision note and STACK row'` here.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `feat: crawler finds a page with two H1 headings and stores the issue (tracer)` | TestCrawlerTracer passes |
| 2 | A `feat: crawler checks for status, sitemaps, canonical, titles and headings`; B `feat: crawler checks for JSON-LD, hreflang, links, images, raw HTML and the rendered-comparison seam`; C `feat: orphan pages and merged CMS issues with fix routes` | TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped pass |
| 3 | A `feat: Site audit and Crawls screens`; B `feat: site audit settings and the nightly crawl`; C `feat: MCP tools for crawls and issues`; D `docs: crawler and site audit in README` | admin, schedule and tool tests pass |
| 4 | here: A `docs(33-10): chromedp decision note and STACK row`; seo: B `feat: rendered comparison through headless Chrome` | TestChromeRenderer passes; plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor starts a crawl, the crawler fetches a page with two H1 headings, and the issue appears in the issues API with how to fix it" — one path only</name>
<files>../sm-seo-plugin/models/crawl.go, ../sm-seo-plugin/models/crawl_page.go, ../sm-seo-plugin/models/issue.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280005_create_golem15_seo_crawls.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/crawl/fetch.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/jobs.go, ../sm-seo-plugin/controllers/api/crawls.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files>
<read_first>
- summercms.go/modules/fetchguard/README.md and fetch.go (Client, WithTransport, Result)
- ../sm-seo-plugin/jobs.go, classes/generate.go (job and dispatch), classes/issues.go, controllers/api/issues.go, controllers/api/auth.go
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 7, Pitfall 12, Security Domain)
</read_first>
<action>
Per D-14 and P33-37 to P33-41: migration 202610280005 creates the P33-37 tables; models for crawls, pages and issues. `classes/crawl`: fetch.go builds the client (AllowHostsMode with the site host and its www twin, HTTPS; TrustedMode when config `golem15.seo.crawl_trusted` is true; MaxBytes 5 MiB; Timeout 10 s; the P33-41 User-Agent); crawler.go runs a breadth-first frontier from the home page and the sitemap index (only same-host URLs, two workers, the delay, max pages, CheckIfCanceled between pages, progress through UpdateJobState); parse.go extracts status, Location, title, meta description, canonical, meta robots, X-Robots-Tag, headings, links with text and rel, images with alt/size/loading, JSON-LD scripts and hreflang links with golang.org/x/net/html; checks.go starts with h1_missing and h1_multiple; store.go writes pages, links and issues; fix.go applies P33-38. jobs.go registers `CrawlArgs` (kind golem15.seo.crawl). Routes in the management group: `POST /_seo/api/v1/crawls` (start, P33-39 refusals 409 crawls.already_running / 422 crawls.no_site_url), and `GET /_seo/api/v1/issues` returns stored issues with the Phase 32 keys plus id, severity, source, fix, found_at, ignored, crawl_id. `crawl_site_test.go` holds an httptest site served through fetchguard.WithTransport (home, a page with two H1, sitemap index and file). `TestCrawlerTracer` (Docker): start a crawl through the API as an administrator, run the job, GET /issues lists h1_multiple for that URL with severity error and a fix (code for a template H1, tool for an H1 inside a fake record's content Headings). Commit as listed.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerTracer' -count=1 -v 2>&1 | tail -20</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestCrawlerTracer" line</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "fetchguard.NewClient\|AllowHostsMode" ../sm-seo-plugin/classes/crawl/fetch.go` prints at least one line
- `grep -n "golem15.seo.crawl" ../sm-seo-plugin/jobs.go ../sm-seo-plugin/classes/crawl/crawler.go` prints at least one line
- `grep -n "crawl_trusted" ../sm-seo-plugin/config/config.yaml` prints one line
- the Phase 32 issues tests (TestIssues) still pass
</acceptance_criteria>
<done>A crawl started from the API audits the rendered site and its first finding is listed with how to fix it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: The full set of crawl checks, the rendered-comparison seam, orphan pages, merged CMS issues with fix routes, ignore fingerprints and staging suppression</name>
<files>../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/links.go, ../sm-seo-plugin/classes/crawl/orphans.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/compare.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/issues.go (CMS kinds and their tools), classes/schema/validate.go (33-06), classes/robots.go (grobotstxt matcher)
- ../sm-sitemap-plugin/contract/contract.go (Listing for sitemap consistency)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C23 kinds table: severity, fix, labels, details)
</read_first>
<behavior>
- TestCrawlerChecks: the planted site has one page per kind of C23 and a clean control page; each kind is reported exactly once for its page with its severity and details keys (status, file, line, count, target, from/to, locale, text, what); the control page has none
- sitemap_error for a sitemap URL answering 404; sitemap_noindex for a listed noindex page; sitemap_blocked for a listed URL robots.txt blocks; sitemap_missing for an indexable page found only through links
- broken_link with the linking pages; redirected_link with the final target; generic_anchor for "kliknij tutaj" and "click here" (settings list, case-insensitive whole text)
- jsonld_invalid for unparsable JSON, jsonld_multiple for two scripts, jsonld_required / jsonld_recommended from schema.Validate; FAQPage nodes have no rules
- hreflang_reciprocal when /en/x lists /pl/x but /pl/x does not list /en/x; hreflang_error when an alternate answers 404
- image_alt for an img without alt (alt="" is fine), image_size without width/height, featured_lazy when the og:image's img is lazy
- content_js (check cms) when the H1 lacks the descriptor title, when words < half of Words, or when a CMS-known link is absent from the raw HTML
- With a fake Renderer (test seam) whose rendered HTML adds a same-host link, an H1 or 80 words of text to a planted page, content_js (check rendered) names that link, the H1 or the word growth; an identical rendering adds nothing; the crawl row records render_status done and render_pages
- TestRenderSkipped: without golem15.seo.crawl_chrome_path the crawl finishes with every raw check, render_status skipped and no rendered finding; a Renderer whose Render fails records render_status failed with its note and the crawl still finishes
- orphan_page for a sitemap page no crawled page links to, with suggestions (pages mentioning its title or focus keyphrase)
- TestIssueMerge: CMS issues are stored with source cms at the end of the crawl; image_alt (CMS) names the record type's Tools[image_alt]; h1_in_content appears for a record with TopHeading 2 and an H1 in Headings (a post and a category) and not for TopHeading 0, naming Tools[headings]; a heading_skipped inside a category's pillar text (type without an edit tool) carries fix code; every stored issue has tool or fix code
- Ignored fingerprints stay ignored after a second crawl; a fixed page's issues are gone after the second crawl
- TestStagingCrawl: with the staging lock every page carries noindex, and no noindex-derived kind is reported
</behavior>
<action>
Per D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24 and P33-38: implement each check in checks.go/links.go/orphans.go with severities and details per the C23 table; sitemap consistency reads the fetched sitemap files plus contract.Listing; JSON-LD uses schema.Validate on each parsed graph; title/description duplicates are computed over the crawl; heading checks compare levels in document order; content_js uses the descriptor of the page's record (MatchPath) for title, Words and CMS links (details.check cms). Per P33-55 and the CONTEXT 2026-10-28 rendered comparison, render.go adds the `Renderer` seam and the crawl's render bookkeeping (nil renderer → render_status skipped; Render errors → failed with the note, the crawl continues), compare.go the pure `compareRendered` over the PageFacts parse.go extracts from raw and rendered HTML, and crawler.go calls it after each raw fetch for the first crawl_render_max pages when a renderer exists; the tests inject a fake renderer through an unexported crawl option (the chromedp renderer arrives in Task 4). Orphans use the stored link graph and the descriptor titles plus the SEO entries' focus keyphrases for suggestions (at most 10 each). issues.go gains the CMS kinds image_alt and h1_in_content (descriptor-driven, the latter only when TopHeading is 2) and stores CMS issues at crawl end. The seo-page descriptor gets Tools {edit: seo_update_page}. fix.go applies P33-38 including the fitting-tool rule (H1 findings → Tools[headings], other content findings → Tools[edit], code when the type names none). Fingerprints and the ignore table per P33-37 (content_js includes details.check); staging suppression per Pitfall 12. Every kind's label and summary keys of C23 in both lang files. Tests first, against the planted httptest site. Commit groups A, B, C.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerChecks|TestIssueMerge|TestStagingCrawl|TestRenderSkipped|TestCrawlerTracer|TestIssues' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestCrawlerChecks, TestIssueMerge, TestStagingCrawl or TestRenderSkipped missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "issues.kind_" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 33
- `grep -n "schema.Validate" ../sm-seo-plugin/classes/crawl/checks.go` prints at least one line
- TestIssueMerge asserts that no stored issue has an empty tool together with fix "tool"
- `grep -n "func compareRendered(" ../sm-seo-plugin/classes/crawl/compare.go` and `grep -n "type Renderer interface" ../sm-seo-plugin/classes/crawl/render.go` each print one line
- this task adds no module to ../sm-seo-plugin/go.mod (the headless-browser dependency waits for Task 4's decision note)
</acceptance_criteria>
<done>The audit covers every planned check, compares raw and rendered pages whenever a renderer exists and reports the comparison as skipped otherwise, merges CMS issues and routes each issue to an MCP tool or to the site's code.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Site audit and Crawls screens, the Site audit settings tab, the nightly crawl and the MCP tools</name>
<files>../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/issues.go, ../sm-seo-plugin/controllers/issues/config_list.yaml, ../sm-seo-plugin/controllers/issues/config_form.yaml, ../sm-seo-plugin/controllers/issues/config_filter.yaml, ../sm-seo-plugin/controllers/issues/_issue_details.htm, ../sm-seo-plugin/controllers/crawls.go, ../sm-seo-plugin/controllers/crawls/config_list.yaml, ../sm-seo-plugin/controllers/crawls/config_form.yaml, ../sm-seo-plugin/controllers/crawls/config_filter.yaml, ../sm-seo-plugin/controllers/crawls/_crawl_summary.htm, ../sm-seo-plugin/models/crawl/columns.yaml, ../sm-seo-plugin/models/crawl/fields.yaml, ../sm-seo-plugin/models/issue/columns.yaml, ../sm-seo-plugin/models/issue/fields.yaml, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/classes/partials.go, ../sm-seo-plugin/updates/202610280006_add_golem15_seo_audit_settings.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/controllers/api/crawls.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/schedule.go, ../sm-seo-plugin/commands.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/crawler_test.go</files>
<read_first>
- ../sm-seo-plugin/controllers/notfound.go and controllers/notfound/config_*.yaml, _hint.htm (record and bulk actions, preview headerPartial, row states)
- ../sm-paymentgateway-plugin/controllers/orders.go (Applies/Run record actions, toolbar actions)
- summercms.go/modules/pact/capabilities.go (AdminAction toolbar actions, AdminRecordAction, AdminBulkAction, RowState) and docs/plugins/scheduling.md
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C20 Site audit tab, C23, C24, their copy, Destructive actions, MCP parity)
</read_first>
<behavior>
- TestCrawlAdmin: the Site audit list sorts errors first, hides ignored rows with the default status filter, shows source and fix columns; the preview renders P7 with the C23 sections; recheck re-fetches one URL and toasts issues.rechecked or recheck_fixed; ignore and bulk ignore set the fingerprint; unignore clears it; the Crawls list sorts newest first; the preview renders P6 with one status callout (running, queued, failed danger, cancelled, limit warning, staging extra) and the extra plain render line of P33-55 (render_skipped naming the config key, render_done with the count, render_failed with the note); GET /_seo/api/v1/crawls/{id} and seo_get_crawl answer render {status, pages, note}; cancel keeps fetched pages; deleting a running crawl answers 403 crawls.delete_running
- Start crawl twice → the second refuses with crawls.already_running naming the start time; without a site URL → crawls.no_site_url
- Settings Site audit tab validates the C20 ranges with validation.between; generic_anchors seeded with the en and pl lists
- TestCrawlSchedule: seo:crawl --scheduled with crawl_schedule off does nothing, daily dispatches a crawl with trigger schedule, weekly dispatches only on Mondays; crawls beyond crawl_keep are deleted with their pages and issues
- TestCrawlTools: the nine MCP tools are listed and bound to their routes; GET tools are read-only; TestEveryAdminOperationHasTool passes with both controllers and the toolbar actions
</behavior>
<action>
Per D-14, D-10, UI-SPEC C15/C20/C23/C24 and P33-39/P33-40: controllers `golem15.seo.issues` (Site audit: list → preview, toolbar action start_crawl, bulk action ignore, record actions recheck, ignore, unignore with Applies, row state disabled issues.state_ignored, filters severity, kind, source, fix, status default open) and `golem15.seo.crawls` (Crawls: list → preview, toolbar [start_crawl, delete], record action cancel, filters status and started_at daterange); P6 `_crawl_summary.htm` (with the P33-55 render line as an extra plain paragraph, like the staging one) and P7 `_issue_details.htm` built from curated view models in classes/partials.go with the kit classes only; navigation order of C15 (Site audit icon gauge, Crawls icon globe after Pages; Auto-links comes in 33-11). Migration 202610280006 adds crawl_schedule, crawl_max_pages, crawl_delay, crawl_keep, generic_anchors with the four-place settings pattern and the Site audit tab. Management routes: `GET /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `DELETE /_seo/api/v1/crawls` (ids), `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore` and filters on `GET /issues`; MCP tools start_crawl, cancel_crawl, list_crawls, get_crawl, delete_crawls (destructive), list_issues, recheck_url, ignore_issues, unignore_issues with AdminOperations rows. schedule.go per P33-40 and `seo:crawl [--scheduled]` in commands.go (documented). README: crawler, limits, trusted mode, checks, fix routes, screens, tools, schedule (the rendered comparison section follows in Task 4). Every C23/C24/C20 key and the P33-55 render keys in both lang files. Tests first. Commit groups A to D.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlAdmin|TestCrawlSchedule|TestCrawlTools|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools or TestEveryAdminOperationHasTool missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"start_crawl"\|"cancel_crawl"\|"list_crawls"\|"get_crawl"\|"delete_crawls"\|"recheck_url"\|"ignore_issues"\|"unignore_issues"' ../sm-seo-plugin/mcp.go` prints eight lines
- `grep -n "seo:crawl" ../sm-seo-plugin/schedule.go ../sm-seo-plugin/commands.go` prints a line in each
- `grep -n "golem15.seo.issues\|golem15.seo.crawls" ../sm-seo-plugin/admin_navigation.go` prints two lines
- `grep -c "render_skipped\|render_done\|render_failed" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 3
</acceptance_criteria>
<done>Editors and MCP clients can run, follow, read, recheck and ignore the audit; it also runs on a schedule.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Rendered comparison through headless Chrome: chromedp decision note, the guarded renderer and its configuration</name>
<files>.planning/notes/2026-10-28-chromedp-rendered-crawl.md, .planning/research/STACK.md, ../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/render_chrome.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/crawl_render_test.go</files>
<read_first>
- .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved chromedp version from 33-06 Task 4)
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md (decision note format) and .planning/research/STACK.md (row format)
- ../sm-seo-plugin/classes/crawl/render.go, crawler.go and compare.go (Task 2 seam), classes/crawl/fetch.go (host list, trusted mode, User-Agent)
- summercms.go/modules/fetchguard/README.md and ip.go (IsPrivateAddr)
- .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Alternatives considered: chromedp row; Environment Availability: Chromium)
</read_first>
<precondition>33-06 Task 4 answered "chromedp: approved vX.Y.Z" (recorded at the top of 33-06-SUMMARY.md); on "rejected", record that in this plan's SUMMARY, leave the Task 2 seam reporting the comparison as skipped, and stop this task</precondition>
<behavior>
- TestChromeRenderer (skipped under -short; runs when SEO_TEST_CHROME names a binary or chromium, chromium-browser or google-chrome is on PATH): against an httptest site in trusted mode, a page whose script adds a same-host link /js-only and two paragraphs renders with them, and compareRendered reports content_js check rendered naming /js-only; the page's third-party script (https://cdn.example.test/x.js) and a fetch to http://169.254.169.254/ are recorded as blocked and never reach a server; the temporary profile directory no longer exists after Close
- In guarded mode a site host that resolves to a private address makes the renderer refuse to start (render_status failed, note names the private address) without launching Chrome
- An empty or relative golem15.seo.crawl_chrome_path, or a path that is not an executable regular file, gives render_status skipped (empty) or failed (invalid) and the crawl still finishes with its raw checks
</behavior>
<action>
Per D-70, the CONTEXT 2026-10-28 rendered comparison (optional chromedp; a decision note before go.mod changes) and P33-55, in this order:
- (A, here, before any go.mod change) write `.planning/notes/2026-10-28-chromedp-rendered-crawl.md`: the decision (raw-HTML checks always; the raw-versus-rendered comparison only when golem15.seo.crawl_chrome_path is configured, otherwise reported as skipped), the dependency github.com/chromedp/chromedp at the version approved in 33-06 Task 4, why (a maintained pure-Go DevTools client, no cgo and no Node runtime; RESEARCH Alternatives considered), that Chrome or Chromium is installed by the operator and is absent on servers by default, the P33-55 guard and limits (resolver pinning with fetchguard.IsPrivateAddr, request interception, temporary profile, no credentials, timeouts, one tab, the executable path only from config and why), that third-party scripts are not loaded during rendering, and that only sm-seo-plugin requires it (the framework is untouched, D-90); add the STACK.md row; commit as planning docs.
- (B, ../sm-seo-plugin) `go get github.com/chromedp/chromedp@<approved>`. render.go: `NewRenderer(cfg, siteHosts, trusted)` reads crawl_chrome_path (empty → nil, skipped) and crawl_render_max (default 200), validates the path (absolute, an executable regular file, else failed with the note), and holds the pure helpers `allowRequest(rawURL string, hosts []string, trusted bool) bool` and `resolverRules(hosts map[string][]netip.Addr) string` (leading `MAP * ~NOTFOUND`, then one MAP line per allowed host). render_chrome.go: `chromeRenderer` resolving the site host and its www twin with net.DefaultResolver.LookupNetIP and refusing a private address unless trusted (fetchguard.IsPrivateAddr), building the allocator from chromedp.DefaultExecAllocatorOptions plus ExecPath, UserDataDir (os.MkdirTemp, removed in Close), Flag host-resolver-rules and the P33-41 UserAgent, and nothing that weakens Chrome's sandbox; per page a child context with a 20 s timeout, fetch.Enable for every request with chromedp.ListenTarget handling fetch.EventRequestPaused (ContinueRequest when allowRequest passes, FailRequest with network.ErrorReasonBlockedByClient otherwise, each blocked URL kept for the render note's count), browser.SetDownloadBehavior deny, Navigate, WaitReady body, OuterHTML of html capped at 5 MiB; Close cancels the allocator and removes the profile; crawler.go builds it when a crawl starts and closes it on finish, failure and CheckIfCanceled. config/config.yaml documents `crawl_chrome_path` (empty, commented: optional, operator-installed Chrome or Chromium) and `crawl_render_max: 200`. README: a Rendered comparison section (what is compared, the two keys, the guard, third-party scripts not loaded, render status in P6 and MCP) and the dependency line. `crawl_render_test.go` per the behavior list (tests first). `go vet ./...` and `go test -short ./...` green; commit B.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestChromeRenderer|TestRenderSkipped|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -15</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestChromeRenderer missing from the "--- PASS" lines (a SKIP fails this check: Chromium is installed on the development machine, RESEARCH Environment Availability)</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- the note's commit is older than the first sm-seo-plugin commit that adds chromedp to go.mod: `git log -1 --format=%ct -- .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a smaller number than the last line of `git -C ../sm-seo-plugin log --format=%ct -S chromedp -- go.mod` (newest first, so the last line is the first such commit)
- `grep -n "github.com/chromedp/chromedp" ../sm-seo-plugin/go.mod .planning/research/STACK.md .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a line in each
- `grep -n "host-resolver-rules" ../sm-seo-plugin/classes/crawl/render_chrome.go` and `grep -n "fetchguard.IsPrivateAddr" ../sm-seo-plugin/classes/crawl/render_chrome.go` each print at least one line
- `grep -n "no-sandbox" ../sm-seo-plugin/classes/crawl/render_chrome.go` prints nothing
- `grep -n "crawl_chrome_path\|crawl_render_max" ../sm-seo-plugin/config/config.yaml` prints two lines, and `grep -rn "crawl_chrome_path" ../sm-seo-plugin/models ../sm-seo-plugin/classes/settings_write.go` prints nothing (config only, never a setting)
</acceptance_criteria>
<done>With a Chrome path configured, crawls compare raw and JavaScript-rendered pages inside the same host and address limits as fetchguard; without one they say the comparison was skipped.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| crawler → the site (and anything its pages link to) | outbound HTTP driven by untrusted page content |
| headless Chrome → the site and whatever its scripts request | a browser executing untrusted page JavaScript on the server |
| crawled HTML → issue details → admin partials and MCP | untrusted text displayed to administrators |
| admin/MCP → crawl actions | authenticated operations |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-43 | Elevation of privilege (SSRF) | crawler fetches | high | mitigate | fetchguard AllowHostsMode with only the site host and its www twin, HTTPS, private addresses blocked at dial time, redirects never followed (each hop re-checked as a new URL against the host list); TrustedMode only from Go config golem15.seo.crawl_trusted, never from page content; TestCrawlerChecks plants an external and a private-IP link that must not be fetched |
| T-33-44 | Denial of service | crawl load on the own site | medium | mitigate | two concurrent requests, configurable delay (≥100 ms), max pages, one crawl at a time, nightly schedule |
| T-33-45 | Denial of service | hostile page content | medium | mitigate | 5 MiB body cap, 10 s timeout, parser on x/net/html (no regex over HTML), at most 1000 links stored per page |
| T-33-46 | Tampering (XSS in admin) | issue details from crawled text | high | mitigate | partials render view-model text through the template engine's escaping; MCP answers are JSON; details hold text, never HTML |
| T-33-47 | Elevation of privilege | crawl and issue routes | high | mitigate | golem15.seo.manage on every route and action (manage wrapper), TestCrawlTools and the operations parity |
| T-33-48 | Information disclosure | crawling staging | low | mitigate | trusted mode needed for http staging; staging-lock issues suppressed, P6 says why |
| T-33-49 | Tampering | ignore fingerprints | low | accept | administrators may hide issues; unignore restores them and the ignore records author and time |
| T-33-66 | Elevation of privilege (SSRF) | headless Chrome in the rendered comparison | high | mitigate | Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private refused unless golem15.seo.crawl_trusted) with every other host mapped to NOTFOUND; every request is intercepted and failed unless https (http only in trusted mode) to an allowed host; TestChromeRenderer plants a third-party script and a link-local fetch and asserts both blocked |
| T-33-67 | Denial of service | Chrome CPU and memory on the server | medium | mitigate | one tab at a time, at most crawl_render_max pages (default 200), 20 s per page, DOM capped at 5 MiB, Chrome closed on finish, failure and cancel; a renderer failure marks render_status failed and never fails the crawl |
| T-33-68 | Information disclosure | browser state during rendering | medium | mitigate | a fresh temporary profile per crawl, removed in Close; no cookies, Authorization header, HTTP credentials or extensions; downloads denied; the crawler never signs in |
| T-33-69 | Elevation of privilege | Chrome executable path | high | mitigate | Go config key only (golem15.seo.crawl_chrome_path, from config files or the environment), never an admin setting or MCP-writable value; absolute path to an executable regular file; Chrome's own sandbox stays on (chromedp default flags, nothing added that disables it) |
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | github.com/chromedp/chromedp is [ASSUMED] in RESEARCH's audit and gated by 33-06's blocking human legitimacy checkpoint (Task 4); the decision note and STACK row are committed before go.mod changes, and the approved version is pinned exactly in go.sum; x/net/html and grobotstxt are already required |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit.
- Docker suite: TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools and the earlier suites.
- With Chromium on the machine: TestChromeRenderer passes (not skipped).
</verification>
<success_criteria>
- The crawler audits the rendered site within its limits and stays on the site's host.
- Raw-HTML checks always run; with a Chrome path the raw and JavaScript-rendered pages are compared under the same host and address limits, and without one every crawl says the comparison was skipped (D-70).
- Every issue is stored per URL with a severity and either a fixing tool or fix code.
- Site audit, Crawls, the settings tab, the schedule and nine MCP tools work.
</success_criteria>
## Artifacts this phase produces
- Package `classes/crawl`: crawler, fetch, parse, checks, links, orphans, store, fix, render (`Renderer`, `NewRenderer`, `allowRequest`, `resolverRules`), render_chrome (`chromeRenderer`), compare (`compareRendered`).
- Dependency: `github.com/chromedp/chromedp` (sm-seo-plugin go.mod only); note `.planning/notes/2026-10-28-chromedp-rendered-crawl.md` and a STACK.md row.
- Config keys: `golem15.seo.crawl_chrome_path` (empty: comparison skipped), `golem15.seo.crawl_render_max` (200).
- Lang keys: `crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`.
- Tables: `golem15_seo_crawls` (with `render_status`, `render_pages`, `render_note`), `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_issues`, `golem15_seo_issue_ignores`; settings `crawl_schedule`, `crawl_max_pages`, `crawl_delay`, `crawl_keep`, `generic_anchors`; migrations 202610280005, 202610280006.
- Job kind `golem15.seo.crawl` (`CrawlArgs`); CLI `seo:crawl [--scheduled]`; schedule entry daily 02:30.
- Issue kinds (crawl): `page_status`, `sitemap_error`, `sitemap_noindex`, `sitemap_missing`, `broken_link`, `redirected_link`, `canonical_missing`, `canonical_error`, `h1_missing`, `h1_multiple`, `heading_skipped`, `heading_empty`, `h1_title_mismatch`, `jsonld_invalid`, `jsonld_multiple`, `jsonld_required`, `jsonld_recommended`, `hreflang_reciprocal`, `hreflang_error`, `generic_anchor`, `image_alt`, `image_size`, `featured_lazy`, `content_js`, `orphan_page`; CMS additions `image_alt`, `h1_in_content`.
- Admin: controllers `golem15.seo.issues` (Site audit), `golem15.seo.crawls` (Crawls); partials `issue_details` (P7), `crawl_summary` (P6); Site audit settings tab.
- Routes: `POST/GET/DELETE /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore`; `GET /_seo/api/v1/issues` extended.
- MCP tools: `seo_start_crawl`, `seo_cancel_crawl`, `seo_list_crawls`, `seo_get_crawl`, `seo_delete_crawls`, `seo_list_issues` (extended), `seo_recheck_url`, `seo_ignore_issues`, `seo_unignore_issues`.
- Config key: `golem15.seo.crawl_trusted`.
- Tests: `TestCrawlerTracer`, `TestCrawlerChecks`, `TestIssueMerge`, `TestStagingCrawl`, `TestRenderSkipped`, `TestCrawlAdmin`, `TestCrawlSchedule`, `TestCrawlTools`, `TestChromeRenderer` (`crawl_render_test.go`); test site helper `crawl_site_test.go`; unit tests for compareRendered, allowRequest and resolverRules follow in 33-14.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,377 @@
---
phase: 33-seo-output-audit-and-content
plan: 11
type: execute
wave: 7
depends_on: ["33-10", "33-09"]
files_modified:
- ../sm-seo-plugin/models/autolink.go
- ../sm-seo-plugin/models/autolink/columns.yaml
- ../sm-seo-plugin/models/autolink/fields.yaml
- ../sm-seo-plugin/models/registry.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/updates/202610280007_create_golem15_seo_autolinks.go
- ../sm-seo-plugin/updates/registry.go
- ../sm-seo-plugin/classes/autolinks.go
- ../sm-seo-plugin/classes/linkgraph.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/autolinks.go
- ../sm-seo-plugin/controllers/autolinks/config_list.yaml
- ../sm-seo-plugin/controllers/autolinks/config_form.yaml
- ../sm-seo-plugin/controllers/autolinks/config_filter.yaml
- ../sm-seo-plugin/controllers/api/autolinks.go
- ../sm-seo-plugin/controllers/api/links.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/assets/src/elements/links.ts
- ../sm-seo-plugin/assets/src/elements/index.ts
- ../sm-seo-plugin/assets/src/elements/labels.ts
- ../sm-seo-plugin/assets/src/elements/styles.css
- ../sm-seo-plugin/assets/dist/seo.js
- ../sm-seo-plugin/assets/dist/seo.css
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.yaml
- ../sm-seo-plugin/README.md
- ../sm-seo-plugin/linking_test.go
- ../sm-journal-plugin/classes/image_variants.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/console/image_variants.go
- ../sm-journal-plugin/models/image_variant.go
- ../sm-journal-plugin/models/registry.go
- ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go
- ../sm-journal-plugin/updates/registry.go
- ../sm-journal-plugin/jobs.go
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/config/config.yaml
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/go.sum
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/related_media_test.go
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue
- ../vue-journal-layer/app/components/Journal/PostCard.vue
- ../vue-journal-layer/app/journal-pages/PostPage.vue
- ../vue-journal-layer/app/utils/srcset.ts
- ../vue-journal-layer/app/utils/types.ts
- ../vue-journal-layer/tests/srcset.test.ts
- ../vue-journal-layer/testing/fixtures/acme/posts.json
- ../vue-journal-layer/testing/expect-acme.txt
- ../vue-journal-layer/README.md
- modules/lagoon/attach/variant.go
- modules/lagoon/attach/webp/webp.go
- modules/lagoon/attach/webp/webp_smoke_test.go
- modules/lagoon/attach/README.md
- modules/lagoon/README.md
- docs/database/attachments.md
- go.mod
- go.sum
- .planning/notes/2026-10-28-webp-gen2brain.md
- .planning/research/STACK.md
autonomous: true
requirements: [SC-2, SC-3, D-82, D-85, D-71, D-10]
estimate:
tokens: 165000
raw_tokens: 165000
tasks: 4
confidence: low
must_haves:
truths:
- "SEO › Auto-links holds phrase → URL rows (per locale or all, active switch, origin, note) with the C22 list, form, filters and refusals (phrase under 3 characters, duplicate phrase in a locale, URL not a / path or http(s) address); the resolve answer of an ok page carries autolinks [{phrase, url}] for its locale without disabled rows and without links to the page itself, plus autolink_max from the Links tab (default 3, 0 to 20, 0 turns auto-linking off), which the layer applies at render (D-82)"
- "Add focus keyphrases (toolbar action on Auto-links, also an MCP tool) creates one row per record whose focus keyphrase in a locale is not yet a phrase there, with the record's URL and origin keyphrase, and toasts the count (D-82, UI-SPEC C22)"
- "The SEO tab shows E10 Internal links for content with a body: inbound and outbound link counts from the last finished crawl, the orphan warning when nothing links to the page, up to 10 pages this text could link to and 10 pages that could link here (titles and focus keyphrases found in the crawled text), each with Copy address; before the first crawl it says so (D-82, UI-SPEC C19)"
- "MCP has seo_list_autolinks, seo_create_autolink, seo_update_autolink, seo_delete_autolinks, seo_autolinks_from_keyphrases and seo_get_link_suggestions; TestEveryAdminOperationHasTool maps the Auto-links controller and the seo_links widget action (D-10)"
- "WebP is encoded in Go with github.com/gen2brain/webp at the version approved at 33-06's legitimacy checkpoint, recorded first in .planning/notes/2026-10-28-webp-gen2brain.md (with the 2026-10-28 benchmark) and a STACK.md row, then added to go.mod; the encoder lives in the framework sub-package modules/lagoon/attach/webp (generic media, additive, no SEO wording), so only binaries that import it link it (CONTEXT 2026-10-28 image formats, D-85, D-90, D-106)"
- "A Journal conga job (golem15.journal.image_variants), dispatched after a featured image upload commits and runnable for all images with journal:image-variants --all, writes thumbnails at golem15.journal.image_widths (default 480, 800, 1200) and a WebP variant of each next to the thumbnail; the upload request never encodes (2026-10-28 image formats)"
- "The Journal API's featured_images entries gain variants [{width, height, url, webp_url}] (additive key), and the journal layer renders featured images as <picture> with a WebP source and the original format as srcset fallback, explicit width and height, alt, lazy loading in cards and eager loading with fetchpriority high for the post's main image (D-85, D-71)"
- "AVIF is not produced (deferred); body images inserted through the editor keep their original file (the variants follow the thumbnail pipeline of attachments)"
- "The Auto-links list shows autolinks.list_empty when empty, with Add focus keyphrases on the same toolbar (UI-SPEC empty: Auto-links list)"
- "E10 shows notCrawled instead of counters before any crawl and none under an empty suggestion list; it is absent on create (UI-SPEC empty: E10)"
- "E10 shows the checking line with aria-busy while loading, and a failure shows the 13px danger line with the SPA's danger toast (UI-SPEC loading and error: E10)"
- "A 422 on the Auto-links form shows the field lines and the count banner (UI-SPEC error: save refused)"
- {statement: "A WebP variant generated twice for the same thumbnail is written once (the job is idempotent through the bucket's Exists), and a failed encode leaves no partial file", verification: backstop}
prohibitions:
- requirement_id: "D-82"
category: safety
status: resolved
verification: test
statement: "An auto-link never points a page to itself, and a disabled or other-locale phrase never reaches a page's resolve answer"
- requirement_id: "D-85"
category: safety
status: resolved
verification: test
statement: "Image encoding never runs inside the upload request; a slow or failing encode never fails the upload"
artifacts:
- path: ".planning/notes/2026-10-28-webp-gen2brain.md"
provides: "decision note naming github.com/gen2brain/webp and the benchmark"
contains: "github.com/gen2brain/webp"
- path: "modules/lagoon/attach/webp/webp.go"
provides: "WebP encoder and variant writer"
contains: "gen2brain/webp"
- path: "../sm-seo-plugin/classes/autolinks.go"
provides: "phrase table service and resolve filtering"
contains: "func "
- path: "../sm-seo-plugin/assets/src/elements/links.ts"
provides: "E10 golem15-seo-links"
contains: "golem15-seo-links"
- path: "../sm-journal-plugin/classes/image_variants.go"
provides: "the image variants job"
contains: "image_variants"
key_links:
- from: "../sm-seo-plugin/classes/resolver.go"
to: "../sm-seo-plugin/classes/autolinks.go"
via: "ok answers carry autolinks for the locale minus self and autolink_max"
pattern: "autolink"
- from: "../sm-journal-plugin/classes/image_variants.go"
to: "modules/lagoon/attach/webp/webp.go"
via: "the job calls webp.Variant for each thumbnail width"
pattern: "webp\\.Variant"
- from: "../vue-journal-layer/app/components/Journal/FeaturedImage.vue"
to: "Journal API featured_images[].variants"
via: "<picture> sources built by srcset.ts from variants"
pattern: "image/webp"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan adds smart internal linking (auto-links, link suggestions with counters) and modern image formats (WebP variants with srcset). The opt-in related posts and category neighbours moved to 33-09 (Task 4) in the 2026-10-28 revision, and the WebP package-legitimacy checkpoint to 33-06 (Task 4), so this plan stays within its task budget and no task spans more than two repositories.
<objective>
Across four repositories (each main, not pushed; tags in 33-14), at most two per task:
- sm-seo-plugin: the Auto-links table and screen with the keyphrase feed and MCP tools, autolinks in the resolve answer, E10 and its tool (D-82, D-10);
- summercms.go: the WebP encoder in `modules/lagoon/attach/webp` after the decision note (D-85, D-90, D-106);
- sm-journal-plugin: the image variants job and the API's variants (CONTEXT 2026-10-28 image formats, D-85);
- vue-journal-layer: `<picture>` with WebP and the original as fallback (D-85, D-71).
Purpose: the internal-linking and media parts of D-82 and D-85 that the earlier plans do not cover.
Output: commits in the four repositories; the decision note and STACK row as a planning-docs commit here before go.mod changes.
**Planner decisions recorded here:**
- **P33-42 (where WebP lives):** in the framework as a generic, additive media helper (`modules/lagoon/attach/webp`, package `webp`: `Encode(w io.Writer, img image.Image) error` at quality 80 and `Variant(ctx, bucket, f *attach.File, w, h int, mode string) (key string, err error)` writing `<thumb key>.webp` next to the thumbnail), with a generic hook in attach (`attach.VariantKey(thumbKey, format string) string`). It is not SEO code (D-90), it is a patch-level addition (D-106), and as a separate sub-package only binaries that import it link the encoder. The job lives in Journal because only plugins register conga jobs.
- **P33-43 (variants table):** `golem15_journal_image_variants` (file_id, width, height, key, webp_key, created_at; unique (file_id, width)) so the API lists variants with one query instead of storage lookups.
- **P33-44 (related options):** moved to 33-09 with the related-posts task.
- **P33-45 (auto-link table):** `golem15_seo_autolinks` (id, phrase, url, locale null = all, enabled, origin manual|keyphrase|mcp, note, timestamps; unique (lower(phrase), coalesce(locale, ''))); settings column autolink_max (default 3) on the Links tab; the resolve answer filters rows by locale, enabled and target ≠ the page's path, ordered by phrase length descending so longer phrases win.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md
@.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md
@.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md
@CLAUDE.md
<interfaces>
framework modules/lagoon/attach/thumb.go: ThumbFilename(id, w, h, offsetX, offsetY, mode, ext), PartitionDirectory, (*File).ThumbKey(ctx, bucket, w, h, mode) (key, error), encodeImage package var (119-130) switched on ext, content-type switch ~222-228, deleteKey on failure, bucket.Exists; thumb_test.go.
sm-seo-plugin: Links tab and settings_write.go four-place pattern (33-05), resolve Result (33-05/06 keys), crawl tables golem15_seo_crawl_pages (text), golem15_seo_crawl_links (33-10), entries' focus keyphrase (Phase 32 SEO tab), SEO tab widgets (seo_snippet, seo_graph) and the element bundle (33-06), operations.go/mcp.go.
sm-journal-plugin: controllers/api/posts.go featuredImages (~629), UploadFeaturedImage (~357-430), jobs (none yet; follow ../sm-seo-plugin/jobs.go), config/config.yaml (33-02, related keys from 33-09), related_media_test.go (33-09).
vue-journal-layer: FeaturedImage.vue, PostCard.vue and PostPage.vue (33-09), utils/types.ts (featured_images).
Benchmark (33-CONTEXT, 2026-10-28): 1200 px from a 2592×1754 photo, WebP 78 KB against JPEG q80 133 KB; about 90 ms with system libwebp loaded through purego (no cgo), about 510 ms in the WASM fallback; AVIF only about 6% smaller at 1.3× to 20× the CPU.
Package (33-RESEARCH audit): github.com/gen2brain/webp v0.6.4 (2026-07-01, 31 versions, source github.com/gen2brain/webp), [ASSUMED]; legitimacy verified at 33-06 Task 4, the approved version at the top of 33-06-SUMMARY.md.
Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab (autolink_max).
</interfaces>
</context>
## Execution units and resume points
- Commit groups per repository staged by path; planning docs (note, STACK.md) committed here separately from the framework code commit. Every commit keeps that repository's vet/tests (`go test -short ./...`, `npm --prefix … run test`) green.
- Resume check per repository: `git -C <repo> log --format=%s | grep -Fx '<subject>'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | seo: `feat: auto-link phrases in the resolve answer (tracer)` | TestAutolinksTracer passes |
| 2 | seo: A `feat: auto-links screen, keyphrase feed and MCP tools`; B `feat: internal links widget with counts and suggestions` | linking tests and the operations parity pass |
| 3 | here: A `docs(33-11): WebP decision note and STACK row`; framework: B `feat(lagoon): WebP variants of attachment thumbnails` | the webp package tests and the additive gate pass |
| 4 | journal: A `feat: image variants job and variants in the post API`; layer: B `feat: WebP and srcset for featured images` | plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor adds an auto-link phrase and the next page that resolves carries it, never on the target page itself" — one path only</name>
<files>../sm-seo-plugin/models/autolink.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/updates/202610280007_create_golem15_seo_autolinks.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/classes/autolinks.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/settings_write.go, ../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/linking_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/resolver.go (Outbound from 33-05, the 33-06 keys)
- ../sm-seo-plugin/classes/settings_write.go and models/settings/fields.yaml (Links tab from 33-05)
- ../vue-seo-layer/app/utils/bodyLinks.ts (33-09, the consumer of autolinks)
</read_first>
<action>
Per D-82 and P33-45: migration 202610280007 creates golem15_seo_autolinks and the settings column autolink_max (default 3); the Links tab gets autolink_max (number, left, settings.autolink_max_comment, range 0–20) through the four-place pattern; `classes/autolinks.go` CRUD with history (so the purge of 33-05 runs) and `ForPage(ctx, locale, selfPath) ([]Autolink, error)`; resolver adds `autolinks` and `autolink_max` to ok answers (empty when autolink_max is 0). `TestAutolinksTracer` (Docker): create "lwia grzywa" → /grzyby/lwia-grzywa (all locales) and a disabled phrase; resolving another page answers autolinks [{phrase: "lwia grzywa", url: "/grzyby/lwia-grzywa"}] and autolink_max 3; resolving /grzyby/lwia-grzywa leaves it out; an en-only phrase is absent on a pl page; autolink_max 0 answers none. Commit as listed.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestAutolinksTracer' -count=1 -v 2>&1 | tail -15</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestAutolinksTracer" line</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'json:"autolinks\|json:"autolink_max' ../sm-seo-plugin/classes/resolver.go` prints two lines
- `grep -n "autolink_max" ../sm-seo-plugin/classes/settings_write.go ../sm-seo-plugin/models/settings/fields.yaml` prints a line in each
</acceptance_criteria>
<done>Auto-link phrases reach every page's resolve answer, where the layer links them.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Auto-links screen with the keyphrase feed and MCP tools; E10 internal links widget with counts and suggestions</name>
<files>../sm-seo-plugin/models/autolink/columns.yaml, ../sm-seo-plugin/models/autolink/fields.yaml, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/autolinks.go, ../sm-seo-plugin/controllers/autolinks/config_list.yaml, ../sm-seo-plugin/controllers/autolinks/config_form.yaml, ../sm-seo-plugin/controllers/autolinks/config_filter.yaml, ../sm-seo-plugin/controllers/api/autolinks.go, ../sm-seo-plugin/controllers/api/links.go, ../sm-seo-plugin/classes/autolinks.go, ../sm-seo-plugin/classes/linkgraph.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/assets/src/elements/links.ts, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/styles.css, ../sm-seo-plugin/assets/dist/seo.js, ../sm-seo-plugin/assets/dist/seo.css, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/linking_test.go</files>
<read_first>
- ../sm-seo-plugin/controllers/pages.go and pages/config_*.yaml (33-05 screen pattern), controllers/crawls.go (toolbar action pattern, 33-10)
- ../sm-seo-plugin/assets/src/elements/graph.ts (33-06 widget element pattern) and tests/elements/*
- ../sm-seo-plugin/classes/crawl/orphans.go (33-10 suggestion logic to reuse)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C19, C22 and their copy)
</read_first>
<behavior>
- Auto-links list per C22 (sorted by phrase, row state disabled for inactive, filters locale, origin, active); refusals autolinks.phrase_short, phrase_taken (naming the URL), url_invalid
- from_keyphrases adds rows for records with a focus keyphrase not yet a phrase in that locale (origin keyphrase) and toasts autolinks.keyphrases_added with the count, or keyphrases_none; running it twice adds nothing the second time
- GET /_seo/api/v1/links?url= answers {crawledAt, inbound, outbound, orphan, out[{phrase, title, url}], in[{phrase, title, adminUrl}]} from the last finished crawl (each list at most 10); before any crawl crawledAt, inbound and outbound are null; the seo_links widget action answers the same
- MCP lists list_autolinks, create_autolink, update_autolink, delete_autolinks (destructive), autolinks_from_keyphrases and get_link_suggestions; TestEveryAdminOperationHasTool passes
- Element smoke: E10 renders counters, the orphan callout and both lists from a stubbed answer, notCrawled without a crawl, none under empty lists; Copy address writes the URL and shows copied
</behavior>
<action>
Per D-82, D-10 and UI-SPEC C19/C22: controller `golem15.seo.autolinks` (list 50 per page, checkboxes, setup, toolbar [create, delete, from_keyphrases], form per C22) with navigation Auto-links (icon wand-sparkles) after Pages per C15; `controllers/api/autolinks.go` (GET/POST /_seo/api/v1/autolinks, PUT /autolinks/{id}, DELETE /autolinks with ids, POST /autolinks/from-keyphrases); `classes/linkgraph.go` reads the last finished crawl for inbound/outbound counts and builds suggestions (pages whose crawled text contains this page's title or focus keyphrase, and pages whose title or keyphrase this page's text contains), reusing the orphan suggestion code; `controllers/api/links.go` GET /_seo/api/v1/links; extensions.go adds seo_links (type widget, action seo_links, context update) after seo_graph, omitted for types whose records have no body (Words nil). Element `golem15-seo-links` per C19 (light DOM, `--c-*` colours, ui-locale labels for every E10 key, navigator.clipboard with the hidden-button fallback). Tools and AdminOperations rows. Rebuild dist. Tests first. Commit groups A and B.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestAutolinks|TestLinkSuggestions|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestAutolinks or TestLinkSuggestions missing from the "--- PASS" lines</fails_when>
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -10 && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit, a failed count above 0 in the vitest summary, or the dist check reporting a difference</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"list_autolinks"\|"create_autolink"\|"update_autolink"\|"delete_autolinks"\|"autolinks_from_keyphrases"\|"get_link_suggestions"' ../sm-seo-plugin/mcp.go` prints six lines
- `grep -n "golem15-seo-links" ../sm-seo-plugin/assets/src/elements/index.ts` prints one line
- `grep -n "golem15.seo.autolinks" ../sm-seo-plugin/admin_navigation.go` prints one line
</acceptance_criteria>
<done>Editors manage auto-links and see linking suggestions with counters in the SEO tab; MCP can do the same.</done>
</task>
<task type="auto">
<name>Task 3: WebP decision note and the framework encoder sub-package (summercms.go only)</name>
<files>.planning/notes/2026-10-28-webp-gen2brain.md, .planning/research/STACK.md, modules/lagoon/attach/variant.go, modules/lagoon/attach/webp/webp.go, modules/lagoon/attach/webp/webp_smoke_test.go, modules/lagoon/attach/README.md, modules/lagoon/README.md, docs/database/attachments.md, go.mod, go.sum</files>
<read_first>
- .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved gen2brain/webp version from 33-06 Task 4)
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md, .planning/notes/2026-10-07-embedded-admin-release-gate.md and .planning/notes/2026-10-28-chromedp-rendered-crawl.md (decision note format; the last one from 33-10), .planning/research/STACK.md (row format)
- modules/lagoon/attach/thumb.go (ThumbKey 154-243, encodeImage 119-130, content types, deleteKey, Exists) and thumb_test.go
- docs/database/attachments.md and modules/lagoon/attach/README.md
</read_first>
<precondition>33-06 Task 4 answered "webp: approved vX.Y.Z" for github.com/gen2brain/webp (recorded at the top of 33-06-SUMMARY.md); on "rejected", record WebP as not delivered in this plan's SUMMARY and skip Tasks 3 and 4</precondition>
<action>
Per the 2026-10-28 image-format decision (CONTEXT: WebP now, in Go; a decision note before go.mod changes), D-85, D-90, D-106 and P33-42, in this order:
- (A, before any go.mod change) write `.planning/notes/2026-10-28-webp-gen2brain.md`: decision (WebP variants per thumbnail size in a conga job; AVIF deferred), the dependency github.com/gen2brain/webp at the version approved in 33-06 Task 4, how it runs (purego libwebp or WASM, no cgo), the benchmark from 33-CONTEXT, where the code lives (P33-42 and why it is generic media, not SEO), and that editor body images keep their original file; add the STACK.md row; commit as planning docs.
- (B, framework) `go get github.com/gen2brain/webp@<approved>`; `modules/lagoon/attach/variant.go`: `VariantKey(thumbKey, format string) string` (thumb key with the extension replaced by the format, the same token rule as ThumbFilename); `modules/lagoon/attach/webp/webp.go`: `Encode` (quality 80) and `Variant` (ThumbKey first, return early when the variant exists, encode from the thumbnail image, ContentType image/webp, delete the key on any encode or close error); `webp_smoke_test.go` encodes a generated image and asserts the RIFF/WEBP header and idempotence on a memblob bucket; README rows and docs/database/attachments.md section (no SEO words); `go vet ./... && go test -short ./...` green; commit.
</action>
<verify>
<automated>go test ./modules/lagoon/attach/... -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10 && go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit, a "FAIL" line, or no "ok" line for the webp package</fails_when>
<automated>bash scripts/check-phase33.sh --additive --marker --docs</automated>
<fails_when>non-zero exit, or a "FAIL", "refuse:" or marker hit line</fails_when>
</verify>
<acceptance_criteria>
- `git log --format=%s -- .planning/notes/2026-10-28-webp-gen2brain.md` shows a commit older than the first commit that changes go.mod with gen2brain (`git log --format=%H -S gen2brain -- go.mod`)
- `grep -n "github.com/gen2brain/webp" go.mod .planning/research/STACK.md .planning/notes/2026-10-28-webp-gen2brain.md` prints a line in each
- `grep -rln "gen2brain" modules --include=*.go` lists only files under modules/lagoon/attach/webp/
- `bash scripts/check-phase33.sh --additive --marker` passes (attach changes are additive and name no SEO concept)
</acceptance_criteria>
<done>The framework can write a WebP variant next to any attachment thumbnail, after the decision note, with the encoder linked only into binaries that import it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Journal image variants job and API variants, and WebP with srcset in the journal layer</name>
<files>../sm-journal-plugin/classes/image_variants.go, ../sm-journal-plugin/console/image_variants.go, ../sm-journal-plugin/models/image_variant.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/jobs.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/utils/srcset.ts, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/tests/srcset.test.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
<read_first>
- modules/lagoon/attach/webp/webp.go and attach/variant.go (Task 3)
- ../sm-journal-plugin/controllers/api/posts.go (UploadFeaturedImage ~357-430, featuredImages ~629) and ../sm-seo-plugin/jobs.go (job pattern)
- ../sm-journal-plugin/related_media_test.go (created by 33-09 for related posts; this task adds TestImageVariants)
- ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, PostCard.vue and app/journal-pages/PostPage.vue (as changed by 33-09)
</read_first>
<precondition>Task 3 is committed (the framework's webp package exists through the replace => ../summercms.go)</precondition>
<behavior>
- TestImageVariants (Docker): an upload commits, the job writes the three default widths with WebP keys, the API lists variants on the featured image, a second run writes nothing new, a width larger than the original is skipped, and the upload request returns before any encoding (the job is dispatched, not run inline)
- srcset.test.ts: WebP and original srcset strings from variants plus the original's width; no variants gives no srcset
- Smoke: a fixture post with variants renders <picture> with a source of type image/webp and an img with width, height and alt; a post without variants renders as before
</behavior>
<action>
Per the 2026-10-28 image-format decision, D-85, D-71 and P33-43, in this order:
- (A, journal) migration and model of P33-43; `classes/image_variants.go`: `ImageVariantsArgs{FileIDs []uint}` (kind golem15.journal.image_variants, conga.MaxAttempts(3)) generating each width of `golem15.journal.image_widths` (default [480, 800, 1200]; widths larger than the original are skipped) through attach.ThumbKey and webp.Variant and upserting rows; dispatched with lagoon.AfterCommit from UploadFeaturedImage and the admin featured-image upload; `journal:image-variants [--all]` backfills (documented, so commands_documented_test.go passes); featuredImages adds `variants` (one query) to each entry (additive key); go.mod keeps the framework replace; config/config.yaml documents image_widths; README section; tests first; commit.
- (B, layer) `app/utils/srcset.ts` (pure: srcset strings for WebP and the original from variants plus the original's width) and tests first; FeaturedImage.vue renders `<picture>` with `<source type="image/webp" srcset sizes>` and `<img src srcset sizes width height alt>` (lazy in cards, eager with fetchpriority high on the post page through a prop PostPage passes, decoding async), unchanged when variants are absent; types.ts adds variants; fixtures and an expectation line containing image/webp; README; commit.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestImageVariants|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestImageVariants missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
<automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated>
<fails_when>non-zero exit from any command, or a "not ok" or "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n '"variants"' ../sm-journal-plugin/controllers/api/posts.go` prints one line; `grep -n "image/webp" ../vue-journal-layer/app/components/Journal/FeaturedImage.vue` prints one line
- `grep -n "golem15.journal.image_variants" ../sm-journal-plugin/classes/image_variants.go` prints at least one line
- `grep -n "image_widths" ../sm-journal-plugin/config/config.yaml` prints at least one line
</acceptance_criteria>
<done>Featured images get WebP variants at three widths from a background job, and the sites serve them with srcset and the original as fallback.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin/MCP → auto-link table | untrusted phrases and URLs later rendered into every page |
| uploaded images → encoder | untrusted image bytes decoded and encoded in a job |
| crawl data → E10 | crawled text shown to administrators |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-50 | Tampering (XSS) | auto-link phrases and URLs | high | mitigate | URL must be a / path or an http(s) URL (javascript: and data: refused), phrase is text; the layer inserts them into text nodes with attribute escaping (33-09); TestAutolinks covers the refusals |
| T-33-51 | Denial of service | image decoding in the job | medium | mitigate | attach's existing original-size cap (errOriginalTooLarge) applies before resizing; the job has attempts and runs outside the request; widths larger than the original skipped |
| T-33-52 | Tampering | supply chain of the WebP encoder | high | mitigate | human legitimacy checkpoint (33-06 Task 4), decision note and STACK row before go.mod, exact approved version pinned in go.sum |
| T-33-53 | Elevation of privilege | auto-links, links and keyphrase tools | high | mitigate | golem15.seo.manage on every route and action, operations parity test |
| T-33-54 | Information disclosure | E10 suggestions | low | accept | suggestions come from the public pages the crawler fetched |
| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | package-legitimacy gate: github.com/gen2brain/webp is [ASSUMED] and gated by the blocking human checkpoint 33-06 Task 4 (Task 3 here has it as a precondition); no npm package added |
</threat_model>
<verification>
- sm-seo-plugin: vet, `test -short`, Docker suites TestAutolinksTracer, TestAutolinks, TestLinkSuggestions and the parity tests; element tests and the dist check.
- sm-journal-plugin: TestImageVariants and the API matrix.
- framework: `go vet ./... && go test -short ./...`, the attach tests, `check-phase33.sh --additive --marker`.
- vue-journal-layer: tests, playground build and smoke.
</verification>
<success_criteria>
- Auto-links and link suggestions with counters work.
- WebP variants with srcset are served, after the note and 33-06's legitimacy checkpoint.
- No task spans more than two repositories.
</success_criteria>
## Artifacts this phase produces
- Notes: `.planning/notes/2026-10-28-webp-gen2brain.md`; STACK.md row for `github.com/gen2brain/webp`.
- Framework: `attach.VariantKey`; package `modules/lagoon/attach/webp` with `webp.Encode` and `webp.Variant`; go.mod require `github.com/gen2brain/webp`.
- sm-seo-plugin: table `golem15_seo_autolinks`, settings `autolink_max`, migration 202610280007, controller `golem15.seo.autolinks`, routes `GET/POST /_seo/api/v1/autolinks`, `PUT /_seo/api/v1/autolinks/{id}`, `DELETE /_seo/api/v1/autolinks`, `POST /_seo/api/v1/autolinks/from-keyphrases`, `GET /_seo/api/v1/links`, widget action `seo_links`, element `golem15-seo-links` (E10), resolve keys `autolinks`, `autolink_max`, MCP tools `seo_list_autolinks`, `seo_create_autolink`, `seo_update_autolink`, `seo_delete_autolinks`, `seo_autolinks_from_keyphrases`, `seo_get_link_suggestions`.
- sm-journal-plugin: config key `golem15.journal.image_widths`; table `golem15_journal_image_variants` (migration 202610280002); job kind `golem15.journal.image_variants` (`ImageVariantsArgs`); CLI `journal:image-variants [--all]`; API key `featured_images[].variants`. (The related-posts keys are 33-09's.)
- vue-journal-layer: `app/utils/srcset.ts`, `<picture>` in FeaturedImage.
- Tests: `TestAutolinksTracer`, `TestAutolinks`, `TestLinkSuggestions`, `TestImageVariants`, `webp_smoke_test.go`, `tests/srcset.test.ts`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-11-SUMMARY.md` when done; record the pinned gen2brain/webp version (from 33-06's checkpoint answer).
</output>

View File

@@ -0,0 +1,238 @@
---
phase: 33-seo-output-audit-and-content
plan: 12
type: execute
wave: 8
depends_on: ["33-11"]
files_modified:
- .planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-12-sm-summercmsio-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-12-vue-summercmsio-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md
- ../sm-bm-app/frontend/public/robots.txt
- ../sm-bm-app/frontend/index.html
- ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf
- ../sm-bm-app/scripts/check-noindex.sh
- ../sm-bm-app/DEPLOY.md
autonomous: true
requirements: [SC-1, SC-2, D-72, D-70, D-23, D-97, D-33, D-22, D-30, D-34]
estimate:
tokens: 120000
raw_tokens: 120000
tasks: 3
confidence: low
must_haves:
truths:
- "Against local links (scratch worktrees wired to the sibling repositories with go.work and pnpm link overrides; nothing pushed or tagged), the demo stack serves a Journal post through Nuxt with the CMS title, one canonical, one JSON-LD @graph, hreflang and breadcrumbs, /sitemap.xml as an index of per-type files through the layer's root proxy, and a redirect from the SEO plugin enforced by the layer (SC-1, SC-2, 2026-10-28 release cadence)"
- "The demo's client-only routes /p/** and /presentations/** are declared in seo.noindexRoutes, so its build passes the SSR guard and those routes send X-Robots-Tag noindex, nofollow; removing the declaration makes the build fail (D-70)"
- "The demo's hand-written robots meta on login, register, connect and the signed-in pages is replaced by page meta (seo.robots for auth pages, requiresAuth for signed-in pages), so the layer marks them automatically (D-23)"
- "The demo reports its static routes to the page registry at server start and links the HTML sitemap from its footer (D-33, D-34)"
- "summercms.io's changelog site (Journal on the journal layer, no SEO plugin mounted) runs with seo.cms false: it keeps its titles through the journal layer's fallback meta, passes the SSR guard, and its changelog smoke passes with the updated card and heading markup; the main site keeps its own @nuxtjs/seo static sitemap (CONTEXT 2026-10-28 accepted defaults: summercms.io gets pin bumps and the build guard only)"
- "sm-bm-app has no indexable pages: its frontend serves robots.txt with User-agent: * and Disallow: /, index.html carries <meta name=\"robots\" content=\"noindex, nofollow\">, and nginx adds X-Robots-Tag: noindex, nofollow to every response; there is no SSR migration (D-72)"
- "The app changes that depend on new tags are captured as patches in the phase directory (one per repository) that apply cleanly to each repository's current HEAD; 33-14 applies them after the tags with real pins; sm-bm-app's change needs no tag and is committed locally (2026-10-28 release cadence)"
- "33-12-local-wiring.md records the exact local-link wiring commands so 33-13 and 33-14 can repeat them"
prohibitions:
- requirement_id: "D-72"
category: safety
status: resolved
verification: test
statement: "sm-bm-app never becomes indexable: robots.txt, the meta tag and the header all say noindex/Disallow"
- requirement_id: "D-70"
category: values
status: resolved
verification: judgment
statement: "A noindex declaration in the demo covers only routes that are really client-only or private; no public content route is declared noindex to pass the guard"
artifacts:
- path: ".planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch"
provides: "demo backend config, tests and smoke script for 33-14"
contains: "diff --git"
- path: ".planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch"
provides: "demo Nuxt config: noindex routes, page meta, footer link"
contains: "noindexRoutes"
- path: "../sm-bm-app/scripts/check-noindex.sh"
provides: "D-72 check"
contains: "Disallow: /"
key_links:
- from: "demo Nuxt (vue-demo-app)"
to: "demo Go binary"
via: "vue-seo-layer middleware resolves through NUXT_SEO_API_BASE; root files proxied through NUXT_SEO_BACKEND_BASE"
pattern: "NUXT_SEO_API_BASE"
- from: "../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf"
to: "every bm response"
via: "add_header X-Robots-Tag \"noindex, nofollow\" always"
pattern: "X-Robots-Tag"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan proves the phase end to end on the demo against local links, prepares summercms.io's pin bumps, and closes D-72 on sm-bm-app.
<objective>
- sm-demo-app + vue-demo-app: wire every Phase 33 repository locally, run the stack, and prepare the committable changes (noindex routes, page meta, footer link, config, a Docker test and an SEO smoke script) as patches for 33-14.
- sm-summercmsio-app + vue-summercmsio-app: prepare the Journal and layer pin bumps with `seo.cms: false` as patches; check the build guard and the changelog smoke.
- sm-bm-app: D-72 (robots.txt Disallow, meta noindex, X-Robots-Tag), committed locally.
Purpose: SC-1/SC-2 on a real app before the release; D-72.
Output: four patch files and a wiring note in the phase directory (planning docs commit here), and local commits in sm-bm-app. Nothing is pushed, tagged or deployed (2026-10-28 release cadence: tags and app re-pins in 33-14).
**Planner decisions recorded here:**
- **P33-46 (local wiring, never committed):** the fixed scratch directory `../../scratch-33` (that is /media/nvme/dev/golem15/summercms.io/scratch-33, outside every repository: summercms.io/ is not a git repository) holds detached worktrees of sm-demo-app, vue-demo-app (from its submodule repository), sm-summercmsio-app and vue-summercmsio-app at HEAD, as `../../scratch-33/<repository name>`; 33-13 adds its own two worktrees there. A fixed path keeps every verify command resolvable; Task 1 creates the directory; each plan removes only its own worktrees with `git worktree remove` after its patches are committed (33-13 after its deploy step) and records that in its SUMMARY. Go: a scratch go.work using the worktree plus the sibling plugin checkouts (absolute paths) and replacing the framework with ../summercms.go. Nuxt: pnpm overrides in the scratch worktree pointing vue-seo-layer, vue-journal-layer, vue-menu-layer and vue-faq-layer to the sibling checkouts (`link:`). The commands go into 33-12-local-wiring.md.
- **P33-47 (patch contents):** each patch holds only the changes that stay after the release (config, pages, tests, scripts, docs), generated with `git diff --binary` in the worktree (new files added with `git add -N`), never the wiring; it must `git apply --check` against the repository's current HEAD.
- **P33-48 (demo noindex set):** noindexRoutes `/p/**` and `/presentations/**` (client-only decks); page meta `seo: {robots: 'noindex, nofollow'}` on login, register, connect and link-admin; `requiresAuth: true` on account/security and subscriptions; quizzes and roadmap keep the CMS answer (public) unless their current noindex is intentional, in which case they move to page meta with that value.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/33-seo-output-audit-and-content/33-release-log.md
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
@CLAUDE.md
<interfaces>
Demo: ../sm-demo-app (go.mod requires tags, go.work uses ./plugins/golem15/*, summer.yaml mounts golem15.sitemap and golem15.seo, config/golem15.seo.yaml, deploy/nginx/demo.summercms.io.conf, DEPLOY.md, go_mod_test.go TestGoModRequiresTags, seo_mount_test.go); ../sm-demo-app/vue-demo-app (submodule repository git.golem15.com/golem15/vue-demo-app: nuxt.config.ts routeRules '/p/**' and '/presentations/**' ssr false, @nuxtjs/seo robots/sitemapUrl config, pages with useSeoMeta robots noindex: login, register, connect, roadmap, quizzes/[slug], presentations/*, account/security, p/[slugHash]).
summercms.io: ../sm-summercmsio-app (requires summercms v0.5.0 and sm-journal-plugin v0.1.0; plugins/golem15/journal submodule), ../sm-summercmsio-app/vue-summercmsio-app (vue-journal-layer v0.6.0 pin, @nuxtjs/seo 5.2.1, scripts generate, build:changelog, test:changelog with changelog/testing/expect.txt).
bm: ../sm-bm-app/frontend (Vite SPA: index.html, public/), deploy/nginx/bm.jakubzych.com.conf, DEPLOY.md; another session's uncommitted .planning changes in that repository stay untouched.
Layer runtime config (33-07): NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO_NOTFOUND_TOKEN, NUXT_SEO_ROUTE_REPORT_TOKEN; options seo.cms, seo.noindexRoutes; page meta requiresAuth, seo.robots; <SeoSitemapLink>.
</interfaces>
</context>
## Execution units and resume points
- Planning-docs commits here: the patches and the wiring note (`docs(33-12): app patches for the release`). sm-bm-app commits staged by path (`git -C ../sm-bm-app add <paths>`); its other-session `.planning` changes stay uncommitted.
- Resume check: `git log --format=%s | grep -Fx 'docs(33-12): app patches for the release'` here; `git -C ../sm-bm-app log --format=%s | grep -Fx 'feat: keep the app out of search engines (noindex, Disallow)'`.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | none (scratch run; results recorded in the SUMMARY) | the SEO smoke passes against the local stack |
| 2 | here: `docs(33-12): app patches for the release` (all four patches and the wiring note) | patches apply with git apply --check |
| 3 | bm: `feat: keep the app out of search engines (noindex, Disallow)` | check-noindex.sh passes |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "a visitor and a crawler see the demo's Journal post with CMS head tags, one graph, breadcrumbs and the sitemap index, through Nuxt on local links" — one path only</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md</files>
<read_first>
- ../sm-demo-app/DEPLOY.md (local run, environment), ../sm-demo-app/go.work, ../sm-demo-app/summer.yaml, ../sm-demo-app/vue-demo-app/nuxt.config.ts
- .planning/phases/32-seo-foundation/32-05-SUMMARY.md ("Scratch run" section: the worktree procedure used for the demo in Phase 32)
- ../vue-seo-layer/README.md (runtime config) and ../sm-seo-plugin/README.md (config keys)
</read_first>
<action>
Per P33-46 and the 2026-10-28 release cadence: create the scratch directory `../../scratch-33` and the worktrees in it, wire Go (scratch go.work with the sibling sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin and the framework replace) and Nuxt (pnpm overrides to the four sibling layers), build the demo binary with `summer build` against the wiring, migrate a scratch database (the demo's compose.yaml Postgres or testcontainers), seed one Journal post with a category and a translation, run the Go binary and the Nuxt server with NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE and the tokens set, and run the sitemap rebuild. Then check through the Nuxt server (curl): the post page has one `<script type="application/ld+json">`, one `rel="canonical"`, hreflang links, a nav.seo-breadcrumbs and the CMS title; /sitemap.xml answers an index whose files answer through the layer; a redirect created through the SEO API answers 301 from Nuxt; /p/x answers X-Robots-Tag noindex, nofollow; the registry lists the reported static routes. Write the commands and checks as a runnable script `scripts/smoke-seo.sh BASE_URL` inside the demo worktree (it becomes part of the patch in Task 2) and record every wiring command in 33-12-local-wiring.md.
</action>
<verify>
<automated>bash ../../scratch-33/sm-demo-app/scripts/smoke-seo.sh http://127.0.0.1:3020 2>&1 | tail -20</automated>
<fails_when>non-zero exit, a "FAIL" line, a missing script (the P33-46 worktree was not created), or no final "smoke-seo: ok" line</fails_when>
</verify>
<acceptance_criteria>
- 33-12-local-wiring.md lists the worktree, go.work and pnpm override commands with absolute sibling paths, and the worktrees live under ../../scratch-33 (P33-46)
- smoke-seo.sh checks at least: one ld+json, one canonical, breadcrumbs, the sitemap index and one file, a 301 from the SEO plugin, the noindex header on /p/x
- the SUMMARY records the smoke output
</acceptance_criteria>
<done>The whole phase works together on a real app before anything is released.</done>
</task>
<task type="auto">
<name>Task 2: Demo and summercms.io release patches (noindex routes, page meta, footer link, config, Docker test, changelog site on cms false)</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-sm-summercmsio-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-vue-summercmsio-app.patch, .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md</files>
<read_first>
- the scratch worktrees from Task 1
- ../sm-demo-app/seo_mount_test.go, boot_test.go, go_mod_test.go (Docker test patterns)
- ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, changelog/ (nuxt.config.ts and testing/expect.txt)
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch (patch precedent)
</read_first>
<action>
Per D-70, D-23, D-33, D-34, D-97, P33-47/P33-48 and the CONTEXT 2026-10-28 accepted default (summercms.io: pin bumps and the build guard only), in the scratch worktrees under ../../scratch-33: vue-demo-app nuxt.config.ts adds `seo: {noindexRoutes: ['/p/**', '/presentations/**']}` and removes the @nuxtjs/seo-only keys (robots, the sitemap URL setting) the journal layer no longer understands; pages switch their hand-written robots meta to page meta per P33-48; the footer renders `<SeoSitemapLink>`; the build passes the guard and fails when the noindexRoutes line is removed (check once, then restore). sm-demo-app: config/golem15.seo.yaml (route_report_token from the environment), config/golem15.sitemap.yaml (site_url), DEPLOY.md (new NUXT_SEO_* variables, `/_sitemap/` not exposed, root files served by the layer), deploy/nginx/demo.summercms.io.conf (drop any sitemap.xsl location), `seo_output_test.go` (Docker: sitemap index served, /_seo/api/v1/site answers, resolve of a seeded post carries jsonld and breadcrumbs), and scripts/smoke-seo.sh from Task 1. vue-summercmsio-app: `seo: {cms: false}` for the changelog app, changelog/testing/expect.txt updated for the journal layer's card and heading markup; `npm run generate` and `test:changelog` pass on the wiring. sm-summercmsio-app: when nothing beyond the later pin bumps is needed, 33-12-sm-summercmsio-app.patch holds the single line `# no change beyond the pin bumps in 33-14` (git apply treats it as an empty patch and 33-14 skips it). Export one patch per repository (P33-47), check each with `git -C <repo> apply --check <patch>` against the live repository's HEAD, and commit the patches and the updated wiring note here; then remove this plan's four worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and record it in the SUMMARY.
</action>
<verify>
<automated>git -C ../sm-demo-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch && git -C ../sm-demo-app/vue-demo-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch && echo patches-ok</automated>
<fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when>
<automated>grep -c "noindexRoutes" .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch</automated>
<fails_when>output 0 or a missing file</fails_when>
</verify>
<acceptance_criteria>
- the demo patch contains seo_output_test.go and scripts/smoke-seo.sh; the vue-demo-app patch contains no `useSeoMeta(` line with a hard-coded robots value
- the summercms.io patch (or its note) sets seo.cms false for the changelog app
- the SUMMARY records: demo build passing, the guard failing without the declaration, generate and test:changelog passing
</acceptance_criteria>
<done>The app changes are ready to apply on top of the release tags.</done>
</task>
<task type="auto">
<name>Task 3: sm-bm-app stays out of search engines (D-72)</name>
<files>../sm-bm-app/frontend/public/robots.txt, ../sm-bm-app/frontend/index.html, ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf, ../sm-bm-app/scripts/check-noindex.sh, ../sm-bm-app/DEPLOY.md</files>
<read_first>
- ../sm-bm-app/frontend/index.html, frontend/package.json (build script), deploy/nginx/bm.jakubzych.com.conf, DEPLOY.md, .claude/CLAUDE.md
</read_first>
<action>
Per D-72: add frontend/public/robots.txt (`User-agent: *` and `Disallow: /`), the robots meta `noindex, nofollow` in index.html's head, and `add_header X-Robots-Tag "noindex, nofollow" always;` in each server block of the nginx conf (and in every location that sets its own add_header, since nginx does not inherit add_header there). `scripts/check-noindex.sh` builds the frontend (`npm --prefix frontend run build`) and checks dist/robots.txt, dist/index.html's meta and the conf's header in every server block, printing `check-noindex: ok` or failing. DEPLOY.md notes the header and the check. Commit as listed (local, not pushed).
</action>
<verify>
<automated>bash ../sm-bm-app/scripts/check-noindex.sh 2>&1 | tail -5</automated>
<fails_when>non-zero exit, or no "check-noindex: ok" line</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Disallow: /" ../sm-bm-app/frontend/public/robots.txt` prints one line
- `grep -c "X-Robots-Tag" ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf` is at least the number of server blocks listening on 443
- `git -C ../sm-bm-app status --short .planning` still shows the other session's changes, uncommitted
</acceptance_criteria>
<done>The internal app tells every crawler to stay out, by file, meta and header.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| scratch stack → local services | local-only run with test credentials |
| bm public host → crawlers | an internal app reachable on the internet |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-55 | Information disclosure | bm indexed by search engines | medium | mitigate | robots.txt Disallow, meta noindex and X-Robots-Tag on every response, checked by check-noindex.sh |
| T-33-56 | Information disclosure | tokens in the scratch run | low | mitigate | tokens generated per run into the scratch environment only, never written into the patches or the wiring note |
| T-33-57 | Tampering | patches applied later | medium | mitigate | git apply --check against current HEADs now and again in 33-14 before applying; patches reviewed in 33-14's release checkpoint |
| T-33-SC | Tampering | npm installs | high | accept | no new package; layers come from local sibling checkouts |
</threat_model>
<verification>
- smoke-seo.sh passes against the local demo stack.
- Each patch passes `git apply --check`.
- check-noindex.sh passes in sm-bm-app.
</verification>
<success_criteria>
- The phase works end to end on the demo against local links.
- Release patches for the demo and summercms.io exist; bm is noindexed.
- Nothing pushed, tagged or deployed.
</success_criteria>
## Artifacts this phase produces
- Patches: `33-12-sm-demo-app.patch` (config/golem15.seo.yaml, config/golem15.sitemap.yaml, DEPLOY.md, nginx, `seo_output_test.go`, `scripts/smoke-seo.sh`), `33-12-vue-demo-app.patch` (nuxt.config `seo.noindexRoutes`, page meta, `<SeoSitemapLink>` in the footer), `33-12-sm-summercmsio-app.patch`, `33-12-vue-summercmsio-app.patch` (`seo.cms: false`, changelog expectations).
- Note: `33-12-local-wiring.md`.
- Scratch worktrees (not committed): `../../scratch-33/sm-demo-app`, `../../scratch-33/vue-demo-app`, `../../scratch-33/sm-summercmsio-app`, `../../scratch-33/vue-summercmsio-app` (P33-46), removed after Task 2.
- sm-bm-app: `frontend/public/robots.txt`, robots meta in `frontend/index.html`, X-Robots-Tag in the nginx conf, `scripts/check-noindex.sh`.
- Test (in the demo patch): `TestSEOOutput` (seo_output_test.go).
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-12-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,265 @@
---
phase: 33-seo-output-audit-and-content
plan: 13
type: execute
wave: 8
depends_on: ["33-11"]
files_modified:
- .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch
- .planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md
- .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md
autonomous: false
requirements: [SC-1, SC-2, D-32, D-30, D-22, D-20, D-33, D-70, D-97, D-24]
user_setup:
- service: uat.grzybyfunkcjonalne.pl server
why: "New secrets for the SEO layer and the SEO plugin, generated on the server and never printed"
env_vars:
- name: NUXT_SEO_NOTFOUND_TOKEN
source: "generated on the server (openssl rand -hex 32) into /srv/grzybyfunkcjonalne/nuxt/.env; the same value as golem15.seo.notfound_token in /srv/grzybyfunkcjonalne/go/.env (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN)"
- name: NUXT_SEO_ROUTE_REPORT_TOKEN
source: "optional; unset falls back to the notfound token"
must_haves:
truths:
- "Against local links, the grzybyfunkcjonalne.pl stack (SummerCMS v0.4.4 → the v0.5.2 line, Journal with golem15.journal.tag_pages false and author pages at /autor/:author, the new sitemap format, golem15.seo mounted, the layers on vue-seo-layer) answers every line of scripts/urls.txt exactly as before (Phase 16 D-15: public URLs identical) (SC-1, RESEARCH Pitfall 13)"
- "The site's sitemap moves to the new format in the same delivery: the PHP-era definitions migrate into Additional addresses, /sitemap.xml becomes the index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files with real lastmod, and the XSL view is gone (D-32, D-30)"
- "robots.txt, sitemaps, llms.txt and verification files come from the CMS through the layer's root proxy; the Nuxt app's own robots, sitemap, link-checker and site-config settings are removed; robots.txt's Sitemap: line names the index (D-22, D-97, D-103)"
- "Every page renders exactly one H1, one canonical and one JSON-LD script; post pages carry breadcrumbs through their category; the build passes the SSR guard (D-70, D-52, D-86, D-43)"
- "The static pages /, /kontakt and /trwaja-prace become registry pages through the route report, and their current titles and descriptions move into the CMS through deploy/seo-pages.csv imported with the SEO plugin's CSV import, so the pages lose no metadata (D-33)"
- "404s are reported to the SEO plugin's 404 log through the layer with the new token (D-20)"
- "The uat deployment happens only after Jakub's decision at Task 3 and his go-ahead; production grzybyfunkcjonalne.pl (WinterCMS) is never touched; the deploy is logged in 33-13-deploy-log.md (2026-10-28 release cadence: no push, no tag); Jakub's own check of uat happens at 33-14's release checkpoint (Task 3 there), before anything is tagged"
- "On uat (a non-production environment) the staging lock keeps robots Disallow and noindex, sitemaps are still built, and no IndexNow request is sent (D-22, D-31)"
prohibitions:
- requirement_id: "D-32"
category: safety
status: resolved
verification: test
statement: "No public URL of grzybyfunkcjonalne.pl changes its answer: every urls.txt line passes before any deploy"
- requirement_id: "D-32"
category: values
status: resolved
verification: judgment
statement: "Nothing is deployed without Jakub's go-ahead, and the live WinterCMS site, its database and files are only read"
artifacts:
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch"
provides: "app config, nginx, scripts, docs, seo-pages.csv, tests"
contains: "diff --git"
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch"
provides: "Nuxt config on the SEO layer, footer link, page meta removal, fixtures and expectations"
contains: "diff --git"
- path: ".planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md"
provides: "decision and (if deployed) the uat deploy steps"
contains: "Decision"
key_links:
- from: "grzyby nginx (deploy/nginx/grzybyfunkcjonalne-staging.conf)"
to: "Nuxt (vue-seo-layer root proxy)"
via: "sitemap, robots.txt and llms.txt requests reach Nuxt, which proxies them to Go; /_seo and /_sitemap stay internal"
pattern: "sitemap"
- from: "sm-grzybyfunkcjonalne-app/summer.yaml"
to: "sm-seo-plugin"
via: "golem15.seo mounted next to golem15.sitemap (the submodule is added in 33-14 at the tag)"
pattern: "golem15.seo"
---
<!-- planner-discipline-allow: seo -->
<!-- planner-discipline-allow: sitemap.xsl -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan moves grzybyfunkcjonalne.pl's SummerCMS deployment onto the new sitemap format and the SEO layer, proves its public URLs unchanged on local links, and deploys it to uat only with Jakub's go-ahead.
<objective>
- In scratch worktrees under the fixed directory ../../scratch-33 (P33-46 of 33-12), wired to the sibling repositories and recorded again in 33-13-local-wiring.md: bump the stack (framework, Journal with tag_pages false and author pages, sitemap new format, SEO plugin mounted, layers on vue-seo-layer), run the site's own smoke over every old URL plus the SEO checks.
- Capture the committable changes as two patches (app repository and its Nuxt submodule repository) for 33-14.
- Decide (Task 3) and, if chosen, deploy to uat.grzybyfunkcjonalne.pl from the local build and smoke it; Jakub verifies uat at 33-14's release checkpoint, which already stops for his review before any tag (moved there in the 2026-10-28 revision so this plan stays within its task budget).
Purpose: SC-1 ("grzybyfunkcjonalne.pl runs on the new format") and RESEARCH Pitfall 13 (several versions at once on a live site).
Output: two patches, the wiring note and the deploy log here (planning docs). No push, no tag; the uat deploy only after the checkpoint.
**Planner decisions recorded here:**
- **P33-49 (nginx):** remove `location = /sitemap.xml` and `location = /sitemap.xsl`; every root file goes to Nuxt, whose SEO layer proxies it to Go on loopback; `/_seo/` and `/_sitemap/` are not exposed (the layer calls Go internally); check-deploy.sh and smoke.sh follow (the sitemap is no longer a "remote" Go path).
- **P33-50 (static page metadata):** the pages' own useSeoMeta calls are removed (the layer owns the head, P33-28); `deploy/seo-pages.csv` (URL, title, description in the SEO plugin's records-by-URL CSV format) carries today's titles and descriptions and is imported after the first route report.
- **P33-51 (deploy target):** uat.grzybyfunkcjonalne.pl only, following ../sm-grzybyfunkcjonalne-app/DEPLOY.md (build, check-deploy, rsync, migrate, supervisor restart, nginx reload), with the new secrets of user_setup generated on the server.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md
@.planning/phases/32-seo-foundation/deferred-items.md
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
@CLAUDE.md
<interfaces>
../sm-grzybyfunkcjonalne-app: go.mod (summercms replaced by ../summercms.go, plugins replaced by ./plugins/golem15/*), go.work, summer.yaml (user, translate, journal, sitemap, menu, faq, grzyby), config/menu_items.yaml (journal-post /blog/:slug, journal-category /:slug), config/*.yaml, deploy/nginx/grzybyfunkcjonalne-staging.conf (location = /sitemap.xml and = /sitemap.xsl to Go at 145-161), deploy/env.example, deploy/nuxt.env.example, deploy/supervisor/*, scripts/{build.sh, check-deploy.sh, migrate.sh, smoke.sh, urls.txt}, testdata/companions/sitemap-*.json(l), boot_test.go, companions_test.go, DEPLOY.md.
vue-grzybyfunkcjonalne-app (submodule repository git.golem15.com/golem15/vue-grzybyfunkcjonalne-app): nuxt.config.ts (extends journal, menu, faq layers; robots, sitemap, linkChecker, site keys; journal routes and perPage; routeRules swr), package.json (layer pins v0.4.0/v0.2.0; smoke:ssr through node_modules/vue-journal-layer/testing/smoke-ssr.mjs), app/pages/{index,kontakt,trwaja-prace}.vue (useSeoMeta), app/components/SiteFooter.vue, tests/fixtures/api/*, tests/fixtures/expect.txt, tests/ssr.test.ts.
Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem15.journal.tag_pages: false.
33-12-local-wiring.md (if 33-12 already ran) shows the same wiring for the demo.
</interfaces>
</context>
## Execution units and resume points
- Planning-docs commits here only: `docs(33-13): grzyby patches for the release` and `docs(33-13): uat deploy log`. Nothing is committed in the app repositories in this plan.
- Resume check: `git log --format=%s | grep -Fx '<subject>'`; the deploy log's last line tells where a deploy stopped.
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | none (scratch run; results in the SUMMARY) | smoke.sh and the SEO checks pass locally |
| 2 | `docs(33-13): grzyby patches for the release` | both patches apply with git apply --check |
| 3 | none (decision recorded in the deploy log) | decision recorded |
| 4 | `docs(33-13): uat deploy log` | uat smoke passes, or the log records the deferral; plan complete |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "every old grzybyfunkcjonalne.pl URL answers as before, and the post pages carry the CMS head, one graph and breadcrumbs, with the sitemap index in the new format" — on local links</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md</files>
<read_first>
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md, scripts/build.sh, scripts/smoke.sh, scripts/urls.txt, scripts/migrate.sh
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts and package.json
- .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md if present, else .planning/phases/32-seo-foundation/32-05-SUMMARY.md ("Scratch run")
</read_first>
<action>
Per Pitfall 13 and P33-46's wiring (repeated for this app): detached worktrees of sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app at ../../scratch-33/sm-grzybyfunkcjonalne-app and ../../scratch-33/vue-grzybyfunkcjonalne-app (the fixed P33-46 directory, created if 33-12 has not run yet); scratch go.work using the worktree and the sibling plugin checkouts (sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, sm-menu-plugin, sm-faq-plugin, sm-translate-plugin, sm-user-plugin as they are) plus the framework replace; summer.yaml mounts golem15.seo; config adds `golem15.journal.tag_pages: false`, the journal-author pattern `/autor/:author`, golem15.seo (site_url https://grzybyfunkcjonalne.pl, notfound_token from the environment) and golem15.sitemap (site_url); pnpm overrides link the four layers. Build with scripts/build.sh on the wiring, run scripts/smoke.sh (full rehearsal: migrate, companions import with the sitemap definitions, Go and Nuxt, every urls.txt line), then write scripts/smoke-seo.sh for this site in the worktree (the same checks as the demo's script of 33-12 when it already exists: one H1, one canonical and one ld+json on a post, a category and /kontakt; breadcrumbs on a post; /sitemap.xml index with sitemap-posts-1.xml; the companion url items present as Additional addresses; it ends with "smoke-seo: ok") and run it. Record every wiring command in 33-13-local-wiring.md.
</action>
<verify>
<automated>cd ../../scratch-33/sm-grzybyfunkcjonalne-app && bash scripts/smoke.sh 2>&1 | tail -15</automated>
<fails_when>non-zero exit (including a missing worktree directory), a "FAIL" line, or a urls.txt line reported with a different status</fails_when>
<automated>bash ../../scratch-33/sm-grzybyfunkcjonalne-app/scripts/smoke-seo.sh http://127.0.0.1:3016 2>&1 | tail -15</automated>
<fails_when>non-zero exit, a "FAIL" line, a missing script, or no "smoke-seo: ok" line</fails_when>
</verify>
<acceptance_criteria>
- every urls.txt line not marked remote passes in the full rehearsal
- the SEO checks pass for a post, a category and /kontakt
- 33-13-local-wiring.md lists the commands with absolute paths
</acceptance_criteria>
<done>The site works on the new stack without any public URL changing.</done>
</task>
<task type="auto">
<name>Task 2: grzyby release patches (config, nginx, scripts, docs, seo-pages.csv, Nuxt config on the SEO layer, fixtures)</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch, .planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch, .planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md</files>
<read_first>
- the scratch worktrees from Task 1
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, deploy/env.example, deploy/nuxt.env.example, boot_test.go, companions_test.go
- ../sm-seo-plugin/README.md (CSV import format for records by URL)
</read_first>
<action>
Per D-32, D-22, D-33, D-97 and P33-47/P33-49/P33-50, in the worktrees: app repository — summer.yaml golem15.seo entry; config/golem15.journal.yaml (tag_pages false), config/menu_items.yaml (journal-author), config/golem15.seo.yaml, config/golem15.sitemap.yaml; nginx per P33-49 (and its header comment); scripts/check-deploy.sh (the required locations), scripts/smoke.sh and scripts/urls.txt (sitemap lines are Nuxt lines now; add /sitemap-posts-1.xml), scripts/migrate.sh comments (step 9 sitemap rows go to Additional addresses); deploy/env.example (SEO tokens and site URL keys), deploy/nuxt.env.example (NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO_NOTFOUND_TOKEN), DEPLOY.md (the new variables, root files through Nuxt, the CSV import step, the route report), deploy/seo-pages.csv (P33-50), smoke-seo.sh, boot_test.go (golem15.seo booted) and companions_test.go (sitemap definitions land in extra_urls). Nuxt submodule repository — nuxt.config.ts drops robots, sitemap, linkChecker and site, adds `seo` options (pagination query, htmlSitemap pl /mapa-strony) and keeps the journal routes; SiteFooter renders `<SeoSitemapLink>`; the three pages drop their useSeoMeta; tests/fixtures/api/seo/{resolve,site}.json and tests/fixtures/expect.txt with one-H1, one-canonical and one-ld+json lines; package.json pins stay for 33-14. `go test` (with Docker) and `npm test` / `smoke:ssr` pass on the wiring. Export both patches (P33-47), `git apply --check` them against the live repositories' HEADs, commit them and the wiring note here.
</action>
<verify>
<automated>git -C ../sm-grzybyfunkcjonalne-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch && git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch && echo patches-ok</automated>
<fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when>
<automated>grep -c "seo-pages.csv\|golem15.seo" .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch</automated>
<fails_when>output 0 or a missing file</fails_when>
</verify>
<acceptance_criteria>
- the app patch removes the `location = /sitemap.xsl` block and contains deploy/seo-pages.csv with the three static pages
- the Nuxt patch removes the robots, sitemap, linkChecker and site keys and every useSeoMeta call from app/pages
- the SUMMARY records go test, npm test and smoke:ssr passing on the wiring
</acceptance_criteria>
<done>The site's changes are ready to apply on top of the release tags.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 3: Deploy grzybyfunkcjonalne.pl's SummerCMS stack to uat now, after the tags, or hold</name>
<reversibility rating="one-way">The uat database runs the sitemap migrations (PHP-era definitions copied into Additional addresses, the new tables filled) and the sitemap URLs change shape; rolling back means restoring the database backup and the previous binaries.</reversibility>
<decision>D-32 moves grzyby to the new format in this delivery; the question is when the uat stack switches.</decision>
<context>Task 1 proved every old URL and the SEO output locally. Deploying now uses a binary built from local, unpushed commits (framework, Journal, sitemap, SEO, layers); deploying after 33-14 uses the tagged versions. uat runs as a non-production environment (robots Disallow, noindex; no IndexNow). The live grzybyfunkcjonalne.pl (WinterCMS) is not affected either way. DEPLOY.md's rollback (previous binary and .output, database backup) applies.</context>
<options>
<option id="deploy-now">
<name>Deploy to uat now from the local build</name>
<pros>Real-environment check (nginx, supervisor, Postgres with the imported data) before the tags; problems found before release</pros>
<cons>uat runs untagged code until 33-14 redeploys the tagged build</cons>
</option>
<option id="after-tags">
<name>Deploy in 33-14 after the tags</name>
<pros>uat only ever runs released versions</pros>
<cons>Environment problems surface only after tagging (a fix then needs another patch release)</cons>
</option>
<option id="hold">
<name>Hold the uat deploy</name>
<pros>No change on uat in this phase</pros>
<cons>SC-1's "grzybyfunkcjonalne.pl runs on the new format" stays unverified on a server</cons>
</option>
</options>
<resume-signal>Reply "deploy-now", "after-tags" or "hold"</resume-signal>
</task>
<task type="auto">
<name>Task 4: Deploy to uat (only if Task 3 answered deploy-now) and smoke it, with every step logged</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md</files>
<read_first>
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Rollback)
- .planning/phases/32-seo-foundation/32-06-release-log.md (step logging so a resumed run never repeats a step)
</read_first>
<precondition>Task 3 answered "deploy-now", and Jakub has generated the user_setup secrets on the server (DEPLOY.md: never printed)</precondition>
<action>
Per P33-51: write the decision to 33-13-deploy-log.md first. If the answer is after-tags or hold, record it, remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and stop (33-14 carries the deploy). Otherwise, from the scratch worktree with the patches applied: scripts/build.sh, scripts/check-deploy.sh, back up the uat database and current binaries as DEPLOY.md describes, rsync the binary, config, scripts, Nuxt .output and deploy files, run the migrations, restart both supervisor programs, reload nginx after `nginx -t`, run the sitemap rebuild command, import deploy/seo-pages.csv after the Nuxt server has reported its routes, then run `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (with the UAT basic auth from the server's own loopback, as DEPLOY.md says) and smoke-seo.sh against it. Log each step with its result as it completes; never print a secret. On any failure, follow DEPLOY.md's rollback and log it. Finally write at the end of the log the uat check list Jakub runs at 33-14's release checkpoint (sitemap index and one file, a post's head and breadcrumbs, SEO › Pages and Settings › Sitemaps in /gfadmin, a crawl without staging-lock noindex), and remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46).
</action>
<verify>
<automated>grep -c "^| " .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md</automated>
<fails_when>output 0 (no logged step) or a missing file</fails_when>
</verify>
<acceptance_criteria>
- the deploy log starts with the Task 3 decision
- for deploy-now: the log shows build, check-deploy, backup, upload, migrate, restart, nginx reload, rebuild, CSV import, smoke.sh --url and smoke-seo.sh, each with its result
- no secret value appears in the log or the SUMMARY
- the log ends with the uat check list for 33-14's release checkpoint (deploy-now only)
- `git -C ../sm-grzybyfunkcjonalne-app worktree list` no longer lists ../../scratch-33/sm-grzybyfunkcjonalne-app
</acceptance_criteria>
<done>uat runs the new stack and passes both smokes, or the deferral is on record; Jakub's check of uat is queued for 33-14's release checkpoint.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| local machine → uat server | rsync and SSH as the deploy user |
| uat Nuxt → uat Go | loopback calls with the new tokens |
| internet → uat nginx | public requests (UAT basic auth) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-58 | Information disclosure | new tokens | high | mitigate | generated on the server into the .env files (mode 0600), never printed, never in patches, logs or the SUMMARY |
| T-33-59 | Tampering | untagged build on uat | medium | mitigate | deploy only after the Task 3 decision; sha256 of the uploaded binary compared with the local build (DEPLOY.md); rollback path logged |
| T-33-60 | Elevation of privilege | exposing /_seo and /_sitemap | medium | mitigate | P33-49 keeps both internal (no nginx location), the management API needs backend auth anyway |
| T-33-61 | Denial of service | public URLs changing on the live domain | high | mitigate | only uat is deployed; every urls.txt line must pass first; the WinterCMS site is untouched |
| T-33-SC | Tampering | npm/Go installs | high | accept | no new package beyond those already gated at 33-06's legitimacy checkpoint (chromedp, gen2brain/webp) |
</threat_model>
<verification>
- Local rehearsal: scripts/smoke.sh and smoke-seo.sh pass on the wiring.
- Both patches pass `git apply --check`.
- For deploy-now: the uat smokes pass; Jakub checks uat at 33-14's release checkpoint before any tag.
</verification>
<success_criteria>
- grzybyfunkcjonalne.pl's SummerCMS stack runs on the new sitemap format and the SEO layer with every old URL unchanged.
- Release patches exist; the uat deploy follows Jakub's decision.
</success_criteria>
## Artifacts this phase produces
- Patches: `33-13-sm-grzybyfunkcjonalne-app.patch` (summer.yaml golem15.seo, `config/golem15.journal.yaml`, `config/golem15.seo.yaml`, `config/golem15.sitemap.yaml`, `config/menu_items.yaml` journal-author, nginx, `scripts/check-deploy.sh`, `scripts/smoke.sh`, `scripts/urls.txt`, `scripts/smoke-seo.sh`, `deploy/env.example`, `deploy/nuxt.env.example`, `deploy/seo-pages.csv`, DEPLOY.md, boot and companions tests), `33-13-vue-grzybyfunkcjonalne-app.patch` (nuxt.config `seo` options, `<SeoSitemapLink>` in SiteFooter, page meta removal, `tests/fixtures/api/seo/*`, `tests/fixtures/expect.txt`).
- Notes and logs: `33-13-local-wiring.md`, `33-13-deploy-log.md` (ending with the uat check list for 33-14's release checkpoint).
- Scratch worktrees (not committed): `../../scratch-33/sm-grzybyfunkcjonalne-app`, `../../scratch-33/vue-grzybyfunkcjonalne-app` (P33-46), removed after Task 4.
- Environment variables (uat): `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`, `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`; config keys used: `golem15.journal.tag_pages`, `golem15.seo.site_url`, `golem15.seo.notfound_token`, `golem15.sitemap.site_url`.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-13-SUMMARY.md` when done; state whether uat runs the new stack and on which commits.
</output>

View File

@@ -0,0 +1,327 @@
---
phase: 33-seo-output-audit-and-content
plan: 14
type: execute
wave: 9
depends_on: ["33-12", "33-13"]
files_modified:
- scripts/check-phase33.sh
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
- .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
- modules/pact/content_test.go
- modules/cabana/field_request_options_test.go
- modules/lagoon/attach/variant_test.go
- modules/lagoon/attach/webp/webp_test.go
- ../sm-sitemap-plugin/classes/writer_test.go
- ../sm-sitemap-plugin/classes/slots_test.go
- ../sm-sitemap-plugin/classes/lastmod_test.go
- ../sm-sitemap-plugin/classes/news_test.go
- ../sm-sitemap-plugin/classes/indexnow_test.go
- ../sm-sitemap-plugin/classes/htmlgroups_test.go
- ../sm-sitemap-plugin/classes/types_test.go
- ../sm-seo-plugin/classes/schema/graph_test.go
- ../sm-seo-plugin/classes/schema/validate_test.go
- ../sm-seo-plugin/classes/schema/custom_test.go
- ../sm-seo-plugin/classes/pagination_test.go
- ../sm-seo-plugin/classes/alternates_test.go
- ../sm-seo-plugin/classes/breadcrumbs_test.go
- ../sm-seo-plugin/classes/sitemap_filter_test.go
- ../sm-seo-plugin/classes/registry_test.go
- ../sm-seo-plugin/classes/autolinks_test.go
- ../sm-seo-plugin/classes/crawl/parse_test.go
- ../sm-seo-plugin/classes/crawl/checks_test.go
- ../sm-seo-plugin/classes/crawl/fix_test.go
- ../sm-seo-plugin/classes/crawl/compare_test.go
- ../sm-seo-plugin/classes/crawl/render_test.go
- ../sm-seo-plugin/tests/elements/schema.test.ts
- ../sm-seo-plugin/tests/elements/graph.test.ts
- ../sm-seo-plugin/tests/elements/links.test.ts
- ../sm-seo-plugin/tests/elements/parent.test.ts
- ../sm-journal-plugin/classes/headings_test.go
- ../sm-journal-plugin/classes/image_alt_test.go
- ../sm-journal-plugin/classes/content_facts_test.go
- ../sm-journal-plugin/classes/related_test.go
- ../sm-journal-plugin/tests/blogeditor/outline.test.ts
- ../sm-journal-plugin/tests/blogeditor/forms.test.ts
- ../vue-seo-layer/tests/routes.test.ts
- ../vue-seo-layer/tests/usePagination.test.ts
- ../vue-journal-layer/tests/postTitle.test.ts
- ../vue-faq-layer/tests/block.test.ts
autonomous: false
requirements: [SC-4, SC-1, SC-2, SC-3, D-106, D-90, D-97, D-30, D-43, D-50, D-70, D-14]
estimate:
tokens: 205000
raw_tokens: 205000
tasks: 4
confidence: low
must_haves:
truths:
- "The phase's new code has unit tests, delivered in this last plan (ROADMAP success criterion 4, project rule): framework (descriptor fields, RequestOptions, attach variants and the WebP encoder), sm-sitemap-plugin (writer, slots, lastmod, News, IndexNow, HTML groups, slugs), sm-seo-plugin (schema builder and validators, custom JSON-LD, pagination, alternates, breadcrumbs, sitemap filter, registry, auto-links, crawler parse/checks/fix routes, the rendered comparison's compareRendered, allowRequest and resolverRules, elements E8–E10 and the parent select), sm-journal-plugin (heading conversion for post content and category pillar text, image alt, content facts, related scoring, editor outline and forms), and the layers (routes, pagination, post title, FAQ block) (SC-4)"
- "bash scripts/check-phase33.sh --all passes with Docker, every repository present and no skips: self-test, go, plugins, layers (tests, playground builds, smokes, build-guard fixtures), consumers, docker, admin, docs, marker, additive and compat (Wave 0 gate)"
- "The framework release is a patch tag (the next v0.5.x read from git tag, expected v0.5.2), strictly additive against v0.5.1 and naming no SEO concept or consuming application (D-106, D-90)"
- "Nothing is pushed, tagged, created on git.golem15.com, deployed or posted before Jakub's go-ahead at Task 3, which names the versions, the pushes, the changelog posts and any deploys (2026-10-28 release cadence: all tags in 33-14)"
- "When 33-13 deployed uat.grzybyfunkcjonalne.pl, Jakub checks it at the same checkpoint (the list at the end of 33-13-deploy-log.md) before any tag; a problem there is answered with hold"
- "After the go-ahead, repositories are released in order — framework → sm-journal-plugin → sm-sitemap-plugin (breaking: next minor) → sm-seo-plugin → vue-seo-layer (created with tea) → vue-journal-layer, vue-menu-layer, vue-faq-layer → apps — each plugin requiring the new tags, the layers pinning the new vue-seo-layer tag instead of link:, and each app pinning tags with plugin pointer bumps as separate commits after the plugin is pushed (2026-10-28 release cadence, CLAUDE.md)"
- "The 33-12 and 33-13 patches are applied to the apps after the tags, with grzybyfunkcjonalne-app's sm-seo-plugin submodule added at its tag; each app builds and passes its tests on the tagged versions; sm-demo-app's check-pins passes on a clean worktree"
- "SummerCMS's release tag gets its changelog post on summercms.io the same day through scripts/changelog-post.py (source, then publish); the g15_ token is read by the script and never printed (CLAUDE.md)"
- "Every executed release step is appended to 33-release-log.md as it completes, so a resumed run never re-tags, force-pushes or re-posts"
- "33-VALIDATION.md is filled per task with the real commands and statuses and marked nyquist_compliant true when every row is green"
- "Phase 33 is delivered in the 14 plans Jakub confirmed on 2026-10-28 (2026-10-28 plan list)"
prohibitions:
- requirement_id: "D-106"
category: safety
status: resolved
verification: judgment
statement: "No push, tag, repository creation, deploy or changelog post happens without Jakub's go-ahead, and no version number is hard-coded before the checkpoint"
- requirement_id: "D-106"
category: safety
status: resolved
verification: judgment
statement: "The changelog token is never printed, copied or minted"
artifacts:
- path: "scripts/check-phase33.sh"
provides: "the complete phase gate"
contains: "--all"
- path: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
provides: "executed release steps"
contains: "Executed steps"
- path: "../sm-seo-plugin/classes/schema/graph_test.go"
provides: "graph unit matrix"
contains: "func Test"
- path: "../vue-seo-layer/tests/usePagination.test.ts"
provides: "pagination composable unit tests"
contains: "test("
key_links:
- from: "scripts/check-phase33.sh"
to: "every Phase 33 repository"
via: "the plugins, layers, consumers, docker and compat stages run each repository's tests and builds"
pattern: "vue-seo-layer"
- from: ".planning/phases/33-seo-output-audit-and-content/33-release-log.md"
to: "git tags of the released repositories"
via: "each logged step names the tag object and the pushed commit"
pattern: "tag"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP.
This plan proves the phase with unit tests and the full gate, then releases every repository in order and re-pins the apps — each step only after Jakub's go-ahead.
<objective>
- Complete `scripts/check-phase33.sh` and run `--all` green (tracer of the gate across every repository).
- Unit tests for all new code (project rule: unit tests are the last plan; SC-4).
- After the release checkpoint: tags in release order, the vue-seo-layer remote, pin bumps and the 33-12/33-13 patches in the apps, pushes, the framework changelog post, and deploys only when named.
Purpose: SC-4 and the release of Phase 33 (2026-10-28 release cadence: all tags here).
Output: test commits in each repository, the gate script, the filled validation map, the release log, tags and pushes after the go-ahead.
**Planner decisions recorded here:**
- **P33-52 (versions proposed at the checkpoint, read from git tag):** framework next patch (v0.5.2); sm-journal-plugin next patch or minor (new settings, type and API keys are additive: patch v0.2.6 unless Jakub prefers v0.3.0); sm-sitemap-plugin next minor (breaking, v0.2.0); sm-seo-plugin next minor (new requires and features, v0.2.0); vue-seo-layer v0.1.0; vue-journal-layer next minor (drops @nuxtjs/seo: v0.7.0); vue-menu-layer and vue-faq-layer next minor (v0.5.0). The answer fixes them.
- **P33-53 (release steps are resumable):** each step is: check the log; do it; verify (`git ls-remote --tags origin <tag>`, build of the dependent); append the log line with SHAs. A failure stops the run and is logged; nothing is force-pushed.
- **P33-57 (four tasks, 2026-10-28 revision):** the gate completion that used to be its own task is split by language into the two unit-test tasks (Go stages with the Go tests, the other stages with the TypeScript tests), and 33-13's uat check joins the release checkpoint, so the plan keeps four tasks without a task spanning more repositories than before. The unit tests and the release cannot leave this plan (project rule: unit tests are the last plan; CONTEXT 2026-10-28 release cadence: all tags in 33-14).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md
@.planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
@.planning/phases/33-seo-output-audit-and-content/33-release-log.md
@.planning/phases/33-seo-output-audit-and-content/33-12-SUMMARY.md
@.planning/phases/33-seo-output-audit-and-content/33-13-SUMMARY.md
@.planning/phases/32-seo-foundation/32-06-SUMMARY.md
@.planning/phases/32-seo-foundation/32-06-release-log.md
@CLAUDE.md
<interfaces>
Every plan's SUMMARY (33-01 … 33-13) lists the symbols, routes, tables and tests it created; unit tests target those symbols.
Release precedent: 32-06-SUMMARY.md "Release steps" (go get with GOWORK=off and GOPRIVATE='git.golem15.com/*', README dependency lines, annotated tags, ls-remote checks, demo check-pins on a clean worktree, changelog-post.py source/publish).
Changelog: ../sm-summercmsio-app/scripts/changelog-post.py source vX.Y.Z, then publish vX.Y.Z notes.md --excerpt "…" (## Highlights/## Added/## Changed/## Fixed, no planning IDs); ../sm-summercmsio-app/DEPLOY.md "Posting to the changelog".
Repository creation: tea (git.golem15.com) for vue-seo-layer, name from 33-07-SUMMARY.md.
Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag>.
</interfaces>
</context>
## Execution units and resume points
- Test commits per repository staged by path; planning docs (VALIDATION, release log) in their own commits here. Other sessions' uncommitted files stay untouched in every repository.
- The executor stops at Task 3 (checkpoint) and the continuation runs Task 4; Task 4 is resumable from 33-release-log.md (P33-53).
| Task | Commit groups, in order | Resume point |
|------|-------------------------|--------------|
| 1 | `test: phase 33 gate, Go stages`; one `test: unit tests for …` commit per Go repository (framework, sitemap, seo, journal) | the Go stages of the gate pass with the new Go unit tests |
| 2 | `test: phase 33 gate, layer, consumer and admin stages`; one `test: …` commit per TS repository (sm-seo-plugin elements, sm-journal-plugin editor, vue-seo-layer, vue-journal-layer, vue-faq-layer); `docs(33-14): validation map` | gate --all green; VALIDATION filled |
| 3 | none (go-ahead recorded at the top of the release log) | answer recorded |
| 4 | release steps logged one by one in 33-release-log.md | every named step logged done |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "the Go side of the phase gate runs the Go repositories' new unit tests, Docker suites and guards and passes" — framework, sm-sitemap-plugin, sm-seo-plugin and sm-journal-plugin</name>
<files>scripts/check-phase33.sh, modules/pact/content_test.go, modules/cabana/field_request_options_test.go, modules/lagoon/attach/variant_test.go, modules/lagoon/attach/webp/webp_test.go, ../sm-sitemap-plugin/classes/writer_test.go, ../sm-sitemap-plugin/classes/slots_test.go, ../sm-sitemap-plugin/classes/lastmod_test.go, ../sm-sitemap-plugin/classes/news_test.go, ../sm-sitemap-plugin/classes/indexnow_test.go, ../sm-sitemap-plugin/classes/htmlgroups_test.go, ../sm-sitemap-plugin/classes/types_test.go, ../sm-seo-plugin/classes/schema/graph_test.go, ../sm-seo-plugin/classes/schema/validate_test.go, ../sm-seo-plugin/classes/schema/custom_test.go, ../sm-seo-plugin/classes/pagination_test.go, ../sm-seo-plugin/classes/alternates_test.go, ../sm-seo-plugin/classes/breadcrumbs_test.go, ../sm-seo-plugin/classes/sitemap_filter_test.go, ../sm-seo-plugin/classes/registry_test.go, ../sm-seo-plugin/classes/autolinks_test.go, ../sm-seo-plugin/classes/crawl/parse_test.go, ../sm-seo-plugin/classes/crawl/checks_test.go, ../sm-seo-plugin/classes/crawl/fix_test.go, ../sm-seo-plugin/classes/crawl/compare_test.go, ../sm-seo-plugin/classes/crawl/render_test.go, ../sm-journal-plugin/classes/headings_test.go, ../sm-journal-plugin/classes/image_alt_test.go, ../sm-journal-plugin/classes/content_facts_test.go, ../sm-journal-plugin/classes/related_test.go</files>
<read_first>
- scripts/check-phase33.sh (33-01) and scripts/check-phase32.sh (stage engine)
- every 33-0x-SUMMARY.md and 33-1x-SUMMARY.md "Artifacts" and verification commands (the Go symbols to test and the Docker suites to run)
- the Phase 32 unit test style: ../sm-seo-plugin/classes/{resolver_test,issues_test,helpers_test}.go, modules/cabana/form_extensions_test.go, modules/lagoon/attach/thumb_test.go
</read_first>
<behavior>
- Table-driven, -short-compatible where no database is needed: XML escaping and namespaces, no priority element; slot assignment with deletes, appends and size change; lastmod ignore list and locale rows; News window boundaries (47 h/49 h) and the 1000 cap; IndexNow chunking at 10 000, key format, host filter, answer mapping; HTML groups order, tree and paging; default slug pluralisation; @id links per kind and the publisher/local node; every validator rule; custom JSON-LD caps, script refusal and merge; pagination parse, 301 and invalid cases per form; alternates and x-default; breadcrumb chain with primary parent, fallback and depth cap; filter reasons; route-report merge never deleting; auto-link locale/self filtering; crawler parse of headings, links, images, JSON-LD and hreflang on HTML fixtures; each check's positive and negative case; fix routes (including the fitting-tool rule: H1 → headings tool, a category's skipped level → code); ConvertH1 byte preservation; alt rewrite escaping; content facts (decorative, videos, headings, category pillar headings); related scoring ties; RequestOptions merge precedence and fail-open; VariantKey; WebP encode header and idempotent variant
- Rendered comparison (33-10 Task 2 and 4): compareRendered reports a same-host link, an H1 and a 50-word and 20 % text growth present only after JavaScript, ignores other-host links and smaller growth, and returns nothing for identical facts; allowRequest accepts https on the site host and its www twin, data: and blob:, refuses other hosts, http (except in trusted mode) and every other scheme; resolverRules starts with MAP * ~NOTFOUND and maps exactly the allowed hosts; NewRenderer gives skipped for an empty path and failed for a relative or non-executable one
</behavior>
<action>
Complete the Go side of check-phase33.sh with what the plans delivered: go (framework vet and `test -short`, including the attach and webp packages), plugins' Go part (the three Go plugins' vet and `test -short`; the element and editor vitest suites and both dist checks stay in the stage, unchanged), docker (the Docker suites named in the SUMMARYs of the three plugins, TestChromeRenderer when a Chrome binary is present, and the framework attach tests), docs, marker (framework diff since v0.5.1), additive and compat. Then write the Go unit tests per the behavior list in the files named (package-internal tests where the code is unexported); each repository's `go vet ./...` and `go test -short ./...` green, Docker suites still green. Run `bash scripts/check-phase33.sh --self-test` and the Go stages with Docker (if the Docker socket is denied, run through `sudo -u golem` per CLAUDE.md); fix gate bugs, never weaken a stage. Commit the script, then one test commit per repository.
</action>
<verify>
<automated>bash scripts/check-phase33.sh --self-test && bash scripts/check-phase33.sh --go --plugins --docker --docs --marker --additive --compat 2>&1 | tail -20</automated>
<fails_when>non-zero exit, or a "FAIL", "refuse:", "missing " or "SKIP" line in the output</fails_when>
<automated>go test -short ./modules/pact ./modules/cabana ./modules/lagoon/attach/... -count=1 2>&1 | tail -6 && go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... -count=1 2>&1 | tail -4</automated>
<fails_when>non-zero exit or a "FAIL" line in any output</fails_when>
</verify>
<acceptance_criteria>
- every test file in this task's files exists and contains at least one `func Test`
- `go -C ../sm-seo-plugin test -short ./classes/schema/... ./classes/crawl/... -count=1 -v 2>&1 | grep -c -- "--- PASS"` is at least 30
- `grep -n "TestChromeRenderer" scripts/check-phase33.sh` prints at least one line
</acceptance_criteria>
<done>The Go code of the phase is covered by unit tests, and the Go side of the gate proves it with the Docker suites and guards.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: TypeScript unit tests for the elements, the editor and the layers; the remaining gate stages, --all and the validation map</name>
<files>scripts/check-phase33.sh, ../sm-seo-plugin/tests/elements/schema.test.ts, ../sm-seo-plugin/tests/elements/graph.test.ts, ../sm-seo-plugin/tests/elements/links.test.ts, ../sm-seo-plugin/tests/elements/parent.test.ts, ../sm-journal-plugin/tests/blogeditor/outline.test.ts, ../sm-journal-plugin/tests/blogeditor/forms.test.ts, ../vue-seo-layer/tests/routes.test.ts, ../vue-seo-layer/tests/usePagination.test.ts, ../vue-journal-layer/tests/postTitle.test.ts, ../vue-faq-layer/tests/block.test.ts, .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</files>
<read_first>
- scripts/check-phase33.sh (after Task 1) and .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md
- ../sm-seo-plugin/tests/elements/{host.ts, serp.test.ts} and ../sm-journal-plugin/tests/blogeditor/{helpers.ts, element.test.ts} (harness)
- ../vue-seo-layer/tests/*.test.ts and ../vue-journal-layer/tests/*.test.ts (node --test style)
- .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C17, C18, C19, C28, L2, L5, L6 backstops)
</read_first>
<behavior>
- E8: Service fields round-trip as a JSON string; invalid price shows priceInvalid; parse error shows line and column; over 16 KB shows jsonTooLarge; type options per kind (UI-SPEC E8 backstop)
- E9: nodes, findings with icons and words, noRules line, allGood, checking with aria-busy, one queued request, failure line keeps the last graph
- E10: counters, notCrawled, orphan callout, lists capped at 10, none, copy writes the URL, hidden copy button without clipboard
- parent select: options and empty option from the value
- Editor outline: skipped levels, empty, link-only, H1 flag only when level 1 is not allowed, generic link flag, 300 ms debounce, no content edits
- Editor forms: rel checkboxes disabled for internal URLs, rel written, generic warning without blocking; slug rule examples (UI-SPEC J4 backstop); decorative clears alt
- Layer routes: static paths exclude parameters, requiresAuth and noindex; pattern normalisation for the drift check
- usePagination: hrefs per form, %pages% replacement, prev/next head links at the ends
- Post title: the titleTag decision table for postTitleTag × content_h1_to_h2 (including an absent key)
- FAQ block: headingLevel 0 renders p with aria-labelledby; FAQPage node text stripped of markup
</behavior>
<action>
Write the tests per behavior list; `npm --prefix <repo> run test` green in each repository; rebuild nothing (tests only). Complete the remaining stages of check-phase33.sh with what the plans delivered: layers (per layer: tests, playground build, its smoke command, journal's second expectation file with the postTitleTag env, vue-seo-layer guard:check and purge checks), consumers (sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app build as committed; the 33-12/33-13 patches checked with `git apply --check`) and admin; fix gate bugs, never weaken a stage. Run `bash scripts/check-phase33.sh --all` with Docker. Fill 33-VALIDATION.md's per-task verification map with the real commands of every plan (including the D-70 rendered comparison: TestRenderSkipped, TestChromeRenderer, compare_test.go) and their status, tick the sign-off list and set `nyquist_compliant: true` and `wave_0_complete: true` when all rows are green. Commit the script, then per repository, then the validation map here.
</action>
<verify>
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -4 && npm --prefix ../sm-journal-plugin run test 2>&1 | tail -4 && npm --prefix ../vue-seo-layer run test 2>&1 | tail -4 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -4 && npm --prefix ../vue-faq-layer run test 2>&1 | tail -4 && npm --prefix ../vue-menu-layer run test 2>&1 | tail -4</automated>
<fails_when>non-zero exit, a "not ok" line, a "# fail" count above 0, or a failed count above 0 in a vitest summary</fails_when>
<automated>bash scripts/check-phase33.sh --all 2>&1 | tail -15 && grep -n "nyquist_compliant: true" .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</automated>
<fails_when>non-zero exit, a "FAIL", "refuse:", "missing " or "SKIP" line, no "Phase 33 gate passed" line, or the grep printing nothing</fails_when>
</verify>
<acceptance_criteria>
- each test file of this task exists with at least three test cases
- the --all output lists PASS for all eleven stages
- `grep -n "guard:check\|expect-acme-title-h1" scripts/check-phase33.sh` prints at least two lines
- 33-VALIDATION.md has no "⬜ pending" status left
</acceptance_criteria>
<done>The admin elements, the editor and the layers are covered by unit tests, one command proves the whole phase across every repository, and the validation map is complete.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Release go-ahead (versions, pushes, the new repository, changelog posts, deploys), with the uat check of 33-13</name>
<what-built>All Phase 33 code is committed locally in 13 repositories, the gate passes (Tasks 1 and 2), and the app patches apply. Nothing has been pushed, tagged, created remotely, deployed or posted. If 33-13 answered deploy-now, uat.grzybyfunkcjonalne.pl runs the untagged stack and both smokes passed there (33-13-deploy-log.md).</what-built>
<how-to-verify>
0. Only if 33-13 deployed uat (moved here from 33-13 in the 2026-10-28 revision; the list is at the end of 33-13-deploy-log.md): open https://uat.grzybyfunkcjonalne.pl/sitemap.xml (an index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files; open one file); open a post (view source: one title, one canonical, one application/ld+json script with BlogPosting and BreadcrumbList, breadcrumbs above the content); in /gfadmin, SEO › Pages lists /, /kontakt and /trwaja-prace with their titles and Settings › Sitemaps shows the files table and the migrated Additional addresses; start a crawl in SEO › Crawls and, when it finishes, SEO › Site audit lists issues without staging-lock noindex. Anything wrong: answer "hold" with what is wrong.
1. Review the proposed versions (P33-52, read now from `git tag` in each repository) and the release order of the release log.
2. Review the list of pushes: framework master; sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin main; vue-seo-layer (new repository with tea, name from 33-07) main; vue-journal-layer, vue-menu-layer, vue-faq-layer main; sm-demo-app and vue-demo-app; sm-summercmsio-app and vue-summercmsio-app; sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app; sm-bm-app — with the counts of commits each push carries (including other sessions' commits, as in 32-06).
3. Choose the changelog posts (the framework post is required for a framework tag; plugin posts optional).
4. Name any deploys (demo per ../sm-demo-app/DEPLOY.md; uat.grzybyfunkcjonalne.pl per its DEPLOY.md, on the tagged build; bm per its DEPLOY.md) or none.
5. Answer with the versions, the approved pushes, the posts and the deploys.
</how-to-verify>
<resume-signal>Reply "release: <versions>; push: <list>; posts: <list>; deploy: <list or none>" or "hold" (with what is wrong on uat, when that is the reason)</resume-signal>
</task>
<task type="auto">
<name>Task 4: Release in order, re-pin the apps, post the changelog and deploy what was named — every step logged</name>
<files>.planning/phases/33-seo-output-audit-and-content/33-release-log.md</files>
<read_first>
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md (the go-ahead at its top and any steps already done)
- .planning/phases/32-seo-foundation/32-06-SUMMARY.md (Release steps) and 32-06-release-log.md
- ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-demo-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/DEPLOY.md
- 33-12-local-wiring.md, 33-13-local-wiring.md, 33-13-deploy-log.md
</read_first>
<precondition>Task 3 answered "release: …" (not "hold"), and the answer is recorded at the top of 33-release-log.md</precondition>
<action>
Per the 2026-10-28 release cadence, P33-52 and P33-53, only for what the go-ahead names, appending a log line (step, repository, result, SHAs, tag object) after each step:
1. Pre-checks: `bash scripts/check-phase33.sh --all` green; each repository's branch is ahead of origin only by the expected commits; patches pass `git apply --check`.
2. Framework: annotated tag, push master and the tag, `git ls-remote --tags origin <tag>`.
3. sm-journal-plugin, then sm-sitemap-plugin, then sm-seo-plugin: `GOWORK=off GOPRIVATE='git.golem15.com/*' go get` the new framework tag (and the new sitemap tag for SEO), README dependency lines, build, vet, `test -short`, commit `chore: require SummerCMS <tag>` (and sm-sitemap-plugin <tag>), annotated tag, push main and the tag.
4. vue-seo-layer: create the remote with tea under the confirmed name, set origin, push main, tag v0.1.0 (or the approved version), push the tag.
5. vue-journal-layer, vue-menu-layer, vue-faq-layer: replace the link: dependency with `git+https://git.golem15.com/golem15/vue-seo-layer.git#<tag>`, `pnpm install`, tests, commit `chore: pin vue-seo-layer <tag>`, annotated tag, push; run vue-journal-layer's scripts/check-git-install.sh <tag> from a directory outside the layer.
6. Apps, each plugin pointer first pushed then bumped in the app as its own commit: sm-demo-app (apply 33-12 patches; go get the tags; submodule pointers journal, sitemap, seo; vue-demo-app: apply its patch, pin the layer tags, commit and push vue-demo-app, then bump its pointer in sm-demo-app; tests with Docker and the summercms_mcp tag; check-pins on a clean worktree); sm-summercmsio-app and vue-summercmsio-app (journal bump with its config, layer pins, `seo.cms: false` patch, generate and test:changelog); sm-grzybyfunkcjonalne-app (framework, journal, sitemap tags; `git submodule add -b main` sm-seo-plugin at plugins/golem15/seo checked out at its tag, go.work use; apply the 33-13 patches; vue app pins; build.sh and tests); sm-bm-app (push its local D-72 commit). Push each app as approved.
7. Changelog: `scripts/changelog-post.py source <framework tag>` in ../sm-summercmsio-app, write user-facing notes (## Highlights / ## Added / ## Changed / ## Fixed; no planning IDs, no quick codes, no H1), `publish <tag> notes.md --excerpt "…"`, check the post URL answers 200; plugin posts only if named. Never print, copy or mint the token.
8. Deploys only if named, each per its DEPLOY.md on the tagged builds (uat.grzybyfunkcjonalne.pl as 33-13 Task 4 describes, appending to 33-13-deploy-log.md as well).
9. Commit the release log here (planning docs). The g15office comment on the phase task, and closing w8kupqi3 if it is still open there (the repository todo is already completed), are reported to the orchestrator in the SUMMARY.
</action>
<verify>
<automated>for t in $(grep -oE 'TAG [^ ]+ [^ ]+' .planning/phases/33-seo-output-audit-and-content/33-release-log.md | awk '{print $2"@"$3}'); do repo=${t%@*}; tag=${t#*@}; git -C "$repo" ls-remote --tags origin "$tag" | grep -q "$tag" || { echo "missing $t"; exit 1; }; done; echo tags-ok</automated>
<fails_when>non-zero exit, a "missing" line, or no "tags-ok" line (each released tag is logged as "TAG <repo path> <tag>")</fails_when>
<automated>bash scripts/check-phase33.sh --compat --consumers 2>&1 | tail -6</automated>
<fails_when>non-zero exit or a "FAIL" line</fails_when>
</verify>
<acceptance_criteria>
- the release log has one line per executed step in the release order, each with SHAs, and a TAG line per tag
- every plugin's go.mod requires the new framework tag (no pseudo-version) and sm-seo-plugin requires the new sm-sitemap-plugin tag
- no layer package.json contains "link:" after step 5
- the changelog post URL answers 200 (when a post was approved)
</acceptance_criteria>
<done>Phase 33 is released in order with the apps pinned to the tags, or the hold is on record.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| local repositories → git.golem15.com | pushes, tags and a new repository |
| release script → summercms.io changelog API | token-authenticated post |
| local build → servers | deploys |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-33-62 | Repudiation/Integrity | unapproved release steps | high | mitigate | blocking go-ahead (Task 3) naming every push, tag, post and deploy; each executed step logged with SHAs |
| T-33-63 | Information disclosure | changelog token | high | mitigate | read by changelog-post.py from ~/.config/summercms/journal-token; never printed, copied or minted; refused token → ask Jakub |
| T-33-64 | Tampering | breaking published API | high | mitigate | additive and compat stages of the gate before tagging; the framework tag is a patch |
| T-33-65 | Tampering | pins to moving targets | medium | mitigate | apps pin tags (go.mod versions, submodule pointers at tags, pnpm git URLs with #tag); check-pins on the demo |
| T-33-SC | Tampering | npm/Go installs | high | mitigate | no new package in this plan; lockfiles regenerated only to swap link: for the first-party tag and reviewed in the pin commits |
</threat_model>
<verification>
- `bash scripts/check-phase33.sh --all` green before the checkpoint.
- After release: all logged tags on the remotes, consumers and compat green, the changelog post reachable.
</verification>
<success_criteria>
- Unit tests for all new code (SC-4) and the full gate green.
- Release in order after the go-ahead, apps re-pinned, framework changelog post, deploys only when named.
</success_criteria>
## Artifacts this phase produces
- Unit test files listed in files_modified (framework, three Go plugins, element and editor suites, three layers).
- Completed `scripts/check-phase33.sh` stages; filled `33-VALIDATION.md` (`nyquist_compliant: true`).
- Release log entries (TAG lines) in `33-release-log.md`; tags (versions fixed at Task 3): framework v0.5.x, sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, vue-seo-layer, vue-journal-layer, vue-menu-layer, vue-faq-layer; remote repository vue-seo-layer on git.golem15.com; app commits re-pinning the tags and applying the 33-12/33-13 patches; the summercms.io changelog post for the framework tag.
<output>
Create `.planning/phases/33-seo-output-audit-and-content/33-14-SUMMARY.md` when done; list for the orchestrator the g15office comment to post and whether g15office task w8kupqi3 still needs closing.
</output>

View File

@@ -0,0 +1,179 @@
# Phase 33: SEO output, audit and content - Context
**Gathered:** 2026-10-24, in the Phase 32 discussion (written out for Phase 33 on 2026-10-27)
**Status:** Ready for planning
**Participant:** Dawid (SEO specialist, delegated by Jakub for the SEO discussion); Jakub for the phase split, code layout and planning decisions.
**Source of the decisions:** `.planning/phases/32-seo-foundation/32-CONTEXT.md`. The SEO discussion covered Phases 32 and 33 together; Jakub split the scope afterwards (Q-01, 2026-10-25). This file does not repeat or change any decision. It lists the ones Phase 33 implements, by ID, with the part that belongs to Phase 33, following the Phase 33 column of `.planning/phases/32-seo-foundation/32-DECISIONS-MAP.md`. Read the full decision text in 32-CONTEXT.md; where the two files disagree, 32-CONTEXT.md wins.
<domain>
## Phase Boundary
Phase 32 delivered the SEO data, rules and admin on the backend. Phase 33 makes the rendered sites emit complete SEO output from the CMS, and adds the audit that finds the remaining problems:
- the `sm-sitemap-plugin` rewrite (D-32): XML sitemap index split by type, image and News sitemaps, IndexNow, the HTML sitemap;
- the page registry in `sm-seo-plugin`;
- JSON-LD `@graph`, pagination, hreflang;
- headings, breadcrumbs;
- the shared Nuxt SEO layer and the SSR rules in the layers, with the layers and apps moved onto it;
- the crawler and the issue report;
- internal linking and auto-links, the orphan report;
- media SEO (alt, file names, formats).
The split of responsibility from 32-CONTEXT still holds: the backend plugins own the data, rules and admin, and serve them to the frontend; the Nuxt layers render them into `<head>`, JSON-LD, breadcrumbs, pagination and headings, enforce redirects and noindex on their routes, and guarantee server rendering.
**Builds on:** the Phase 32 releases, as tagged versions, not a `replace` (Q-01): SummerCMS v0.5.1, `sm-seo-plugin` v0.1.0, `sm-journal-plugin` v0.2.5. The framework seams D-91 to D-93 exist and stay as they are.
**Repos:** `sm-sitemap-plugin`, `sm-seo-plugin`, `sm-journal-plugin`, `sm-faq-plugin`, the Nuxt layers (a new SEO layer, journal, menu, FAQ), the apps. grzybyfunkcjonalne moves to the new sitemap format in the same delivery.
**Out of scope** (as in 32-CONTEXT): e-commerce structured data and shop pages; migrating `sm-bm-app` to SSR (D-72 gives it noindex and `Disallow` only).
</domain>
<decisions>
## Implementation Decisions
### Planning (Jakub, 2026-10-25)
- **About 12–15 smaller plans: an exception to the lean-planning rule.** The planner must not merge them into 3–4 plans. The other project rules still apply: the plan list with a one-line scope each is shown to Jakub before PLAN.md files are written, and unit tests are the last plan.
- **Mode:** mvp (ROADMAP.md).
- **H1 in Journal content is a per-site setting (Jakub, 2026-10-28; amends D-50 and D-51).** Journal serves many kinds of sites, and the plugin cannot know whether the page template renders the post title as the H1, so the "no H1 in body content" rule must not be hard-wired. A Journal setting (backend settings and config key) switches it:
- **On (default, D-50 as decided):** H1 to H2 conversion on every write path with the save notice, the editor offers H2–H4, `journal:fix-headings` is available, and the journal layer's htmlGate demotes h1 to h2.
- **Off:** content H1 is stored and rendered as written; the editor offers H1; `journal:fix-headings` refuses to run and says why; the layer does not demote h1 (it reads the setting from the Journal API, not from a layer option).
- The live heading outline (D-51) works in both modes. The crawler keeps checking the rendered page (one H1 per page); the CMS-side "H1 in content" check runs only when the setting is on.
- Where the setting lives (Journal, not the SEO plugin) follows D-96: the content plugin owns its own fixes.
- **Layer options instead of fixed layout assumptions (Jakub, 2026-10-28, from the UI-SPEC check).**
- **Post title heading:** the journal layer's post page renders the title as the H1 when Journal's H1 setting is on, and as a styled non-heading element when it is off, so a post never gets two H1s. A layer option (e.g. `postTitleTag`) overrides either way.
- **Outbound links in a new tab (D-83):** a switch on the SEO settings Links tab, manageable through MCP, default on. When off, outbound links keep their `rel` rules but open in the same tab.
- **Journal breadcrumbs (D-86):** a journal-layer option turns off the journal pages' built-in `<SeoBreadcrumbs>`, for sites whose layout places breadcrumbs itself.
- **The framework still carries no SEO code (D-90).** If Phase 33 needs a new framework seam, it must be generic and additive (D-106 rules: patch tag, no breaking change); D-103 rules out a fourth seam for redirects and verification files.
- **Plan list confirmed (Jakub, 2026-10-28): 14 plans**, following the "Suggested Plan Breakdown" in 33-RESEARCH.md with the 2026-10-28 amendments above added to 33-02 (Journal H1 setting), 33-05 (`links_new_tab`) and 33-09 (`postTitleTag`, `journal.breadcrumbs`, htmlGate following the Journal setting).
- **Release cadence (Jakub, 2026-10-28): all tags in 33-14.** No plugin or layer tag is pushed mid-phase. Plans 33-12 and 33-13 work against local links (`replace`, pnpm `link:`/workspace paths or patches, as 32-05 did); 33-14 tags every repo in release order and re-pins the apps.
- **Image formats (Jakub, 2026-10-28; D-85, RESEARCH Open Question 3): WebP now, in Go.** `github.com/gen2brain/webp` encodes a WebP variant once per thumbnail size, in a conga job (not in the upload request), stored next to the existing thumbnails; the layers emit `srcset` with WebP and the original format as fallback. AVIF is deferred. The dependency needs a decision note in `.planning/notes/` before `go.mod` changes. Benchmark (2026-10-28, 1200 px from a 2592×1754 photo): WebP 78 KB against JPEG q80 133 KB; about 90 ms with system libwebp (loaded through purego, no cgo), about 510 ms in the WASM fallback; AVIF only about 6% smaller than WebP at 1.3× to 20× the CPU.
- **Rendered comparison (Jakub, 2026-10-28; D-70, RESEARCH Open Question 2): optional chromedp.** The crawler's raw-HTML checks always run. The raw-versus-rendered comparison (content and links that appear only after JavaScript) runs when a Chrome path is configured (setting or config key); without one the crawler reports that the comparison was skipped. `github.com/chromedp/chromedp` needs a decision note in `.planning/notes/` before `go.mod` changes.
- **Success criterion 3 reading (Jakub, 2026-10-28; RESEARCH Open Question 4):** every data issue names the MCP tool that fixes it; an issue caused by template code carries `fix: code` instead (UI-SPEC C23).
- **H1 conversion scope (Jakub, 2026-10-28; D-50):** when the Journal H1 setting is on, the H1 to H2 conversion covers every Journal rich-text write path, post content and category pillar text alike; `journal:fix-headings` covers both.
- **Remaining research defaults accepted (Jakub, 2026-10-28):**
- Sitemap site URL precedence: sitemap setting, then `golem15.sitemap.site_url`, then `app.url` (Open Question 5).
- Related posts: opt-in in Journal, current behaviour as default (Open Question 6).
- summercms.io: pin bumps and the build guard only (Open Question 7).
- Auth pages are marked through route meta; no user layer (Open Question 10).
- The new-tab hint is on by default while new tab is on (UI-SPEC Q-8).
### Decisions implemented entirely in Phase 33
Section numbers refer to 32-CONTEXT.md.
| ID | Decision | 32-CONTEXT section |
|----|----------|--------------------|
| D-30 | Sitemap index split by type, 1000-URL files, real `lastmod` | 5 |
| D-31 | Image, News sitemaps, IndexNow | 5 |
| D-32 | The new format replaces PHP parity (breaking, confirmed); the XSLT todo goes with it | 5 |
| D-33 | Page registry in the CMS | 5 |
| D-34 | HTML sitemap | 5 |
| D-40 | URL form chosen per site | 6 |
| D-41 | Page 2 and later: robots, suffixes, H1, pillar text | 6 |
| D-42 | Numbered server-rendered links, rel prev/next, 301 and 404 rules | 6 |
| D-43 | One JSON-LD `@graph` | 7 |
| D-44 | Schema type, type-dependent fields, custom JSON-LD | 7 |
| D-45 | Publisher in the global settings | 7 |
| D-46 | Validation in admin, crawler and Rich Results link | 7 |
| D-50 | No H1 in body content, including Journal's conversion (D-96); behind a Journal setting, see Planning | 8 |
| D-51 | Live heading outline and crawler checks; editor heading levels follow the Journal setting, see Planning | 8 |
| D-52 | One heading convention in the layers | 8 |
| D-55 | hreflang in head and sitemaps from one source | 9 |
| D-56 | `x-default` points to the default locale | 9 |
| D-57 | No hreflang for a missing translation | 9 |
| D-70 | Indexable pages always SSR or SSG; build guard; raw versus rendered check | 10 |
| D-71 | Real status codes, server-rendered links, content in HTML, image rules | 10 |
| D-72 | `sm-bm-app` gets noindex and `Disallow` only | 10 |
| D-80 | One link per post card | 11 |
| D-81 | No generic anchors | 11 |
| D-82 | Related posts, phrase auto-linking, suggestions, orphan report | 11 |
| D-83 | Outbound link rules | 11 |
| D-85 | Media SEO | 12 |
| D-86 | Breadcrumbs component and primary category | 12 |
| D-95 | `sm-sitemap-plugin` with its own filter interface that SEO implements | 0 |
| D-97 | One shared Nuxt SEO layer | 0 |
### Decisions split between Phases 32 and 33 (Phase 33 part only)
Phase 32 delivered the rest of each; see the Phase 32 column of the decisions map.
| ID | Decision | Phase 33 part |
|----|----------|---------------|
| D-03 | Global settings | Organisation or person data for JSON-LD (with D-43, D-45) |
| D-05 | Search engine verification | Serving the files at the site root from the layer (D-103) |
| D-08 | Meta robots, `X-Robots-Tag` | Sending the header on frontend-rendered pages and files (layer, D-97) |
| D-10 | All SEO manageable through MCP on `pier` | Tools for the crawler and the other Phase 33 screens |
| D-12 | Batch tools with `dry_run` | "Noindex on a page in a sitemap" conflict refined by the D-95 filter |
| D-14 | Issue report: CMS data checks plus a crawler | The built-in crawler and its checks |
| D-20 | 404 log | The layer's call to the report endpoint |
| D-20a | Where redirects execute | Nitro middleware in the layer (D-97, D-103) |
| D-20b | URL normalisation per site | Enforcement on frontend routes through the layer |
| D-20c | Strict URLs | Layer routes reading their patterns from the CMS |
| D-21 | Default robots per page kind | Pagination page 2+ (D-41) and signed-in routes (D-23) in the layer |
| D-22 | robots.txt | The `Sitemap:` default moves to the D-30 index |
| D-23 | Private frontend pages noindex | Automatic marking of signed-in routes in the layer and the user plugin's auth layer |
| D-24 | noindex, robots.txt and sitemaps consistent | Noindex, canonicalised and redirected pages leave the sitemaps; "sitemap URL blocked" warning |
| D-25 | AI bot switches, optional llms.txt | llms.txt generated from the sitemap |
| D-93 | Content descriptors in signpost | Sitemaps and the page registry consume `Records` (D-30, D-33) |
| D-94 | `sm-seo-plugin` scope | Page registry, llms.txt, JSON-LD `@graph` and breadcrumbs APIs, crawler |
| D-96 | Content plugins keep only their own fixes | Journal's H1 to H2 conversion (D-50) and schema types (D-43) |
| D-103 | Redirect decision endpoint and verification-file API only | Layer enforcement of redirects and root mapping of verification files |
### Claude's Discretion
- HTML sitemap grouping and paging (D-34).
- Crawler implementation: Go, an in-process job through `conga`/River, politeness limits (D-14).
- Table and package names inside the plugins.
- Site-cache purge after SEO writes: Phase 32 serves effective SEO through one resolver endpoint per URL (P-15) but does not purge. Once the D-97 layer caches the resolve answer, purging through the existing site-cache purge becomes needed; plan it with D-97.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Decisions
- `.planning/phases/32-seo-foundation/32-CONTEXT.md`: full text of every decision listed above, the reference implementations (`mpseo`) and the code seams.
- `.planning/phases/32-seo-foundation/32-DECISIONS-MAP.md`: the Phase 32/33 boundary per decision.
### What Phase 32 delivered
- `.planning/phases/32-seo-foundation/32-0*-SUMMARY.md`: what exists in `sm-seo-plugin` (models, resolver, redirects, robots.txt, verification API, `/_seo/api/v1`, MCP tools, issue report) and the framework seams (D-91 form extensions, D-92 record events, D-93 descriptors).
- `.planning/phases/32-seo-foundation/32-VERIFICATION.md`, `32-UAT.md`: open verification items.
- `.planning/phases/32-seo-foundation/deferred-items.md`: Journal bumps in the apps, plugin release posts and the demo deploy, still open after Phase 32.
- `.planning/phases/32-seo-foundation/32-UI-SPEC.md`: admin elements E1–E7; Phase 33 admin screens follow the same conventions.
### Prior phase decisions
- `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md`: Nuxt layers, SSR with swr/ISR, URLs kept identical.
- `.planning/phases/17-blog-companions/17-CONTEXT.md`: signpost menu-item types and per-locale alternates; D-11 to D-15, the sitemap behaviour D-32 replaces.
- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md`: `pier`, tools bound to admin routes.
### Code
- `../sm-sitemap-plugin/`: `classes/generator.go`, `classes/serialize.go`, `routes.go`, `admin.go`, `models/settings.go`.
- `../sm-seo-plugin/`: the Phase 32 plugin that Phase 33 extends.
- `../sm-journal-plugin/`: `classes/format_html.go` (allows h1 today), `classes/menu_item_types.go`.
- `../vue-journal-layer/`: `nuxt.config.ts` (`@nuxtjs/seo`, sitemap source), `app/utils/seo.ts`, `app/components/Journal/Pagination.vue`, `app/journal-pages/*.vue`.
- Apps: `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` (robots and sitemap config to move to the CMS), `../sm-demo-app/vue-demo-app/` (hand-written noindex pages), `../sm-summercmsio-app/vue-summercmsio-app` (nuxt generate), `../sm-bm-app` (D-72).
- `modules/signpost/signpost.go`, `modules/conga/`: URL registry and jobs for the crawler, IndexNow and sitemap rebuilds.
</canonical_refs>
<specifics>
## Specific Ideas
- Dawid's audit workflow is the reference use case: after a crawl he tells his Claude client what to fix, and it happens through MCP with a dry-run preview. Every crawler issue must be fixable through MCP (ROADMAP success criterion 3).
- Sitemaps are split by type and batched at 1000 URLs so they can be monitored file by file in GSC (D-30).
- The features are framework-wide; no site-specific shaping (32-CONTEXT, Specific Ideas).
</specifics>
<deferred>
## Deferred Ideas
- From 32-CONTEXT: social card preview, per-redirect hit counters and validity dates, 307/308 redirects, e-commerce structured data.
- `sitemap-xslt-browser-deprecation.md` (g15office w8kupqi3) stays a separate backlog task; the D-32 rewrite touches the same code, so the plan should say whether the XSL output is removed or kept.
</deferred>
---
*Phase: 33-seo-output-audit-and-content*
*Decisions gathered: 2026-10-24 (Phase 32 discussion); written out: 2026-10-27*

View File

@@ -0,0 +1,338 @@
# Phase 33: SEO output, audit and content - Pattern Map
**Mapped:** 2026-10-28
**Files analyzed:** ~45 groups of new/modified files across 13 repos
**Analogs found:** 40 / 45 (the rest are in "No Analog Found")
Paths are relative to the meta repo root (`/media/nvme/dev/golem15/summercms.io/summercms`). Every analog below is git-tracked in its own repo (checked with `git ls-files`); nothing points into `.msd/` mirrors.
## File Classification
| New/Modified File | Plan | Role | Data Flow | Closest Analog | Match |
|---|---|---|---|---|---|
| `summercms.go/modules/pact/content.go` (+ `PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind`) | 33-01 | model (seam) | transform | itself, lines 60-81 | exact |
| `summercms.go/modules/signpost/README.md`, `docs/` pages | 33-01 | docs | - | module README structure (CLAUDE.md) | exact |
| `summercms.go/scripts/check-phase33.sh` | 33-01 | config/gate | batch | `summercms.go/scripts/check-phase32.sh` | exact |
| `sm-journal-plugin/classes/content_descriptors.go` (fill new fields) | 33-02 | service | transform | itself (`describePosts`, `firstImages`) | exact |
| `sm-journal-plugin/models/settings.go` + `models/settings/fields.yaml` (H1 setting, e.g. `content_h1_to_h2`) | 33-02 | model | CRUD | same file (`RSSEnabled` bool + switch field) | exact |
| `sm-journal-plugin/updates/2026102800xx_add_golem15_journal_settings_h1.go` | 33-02 | migration | - | `sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go` | exact |
| `sm-journal-plugin/config/config.yaml` (new; config key for the H1 default) | 33-02 | config | - | `sm-seo-plugin/config/config.yaml` + `ConfigFS()` in `sm-seo-plugin/plugin.go:79-80` | exact |
| `sm-journal-plugin/controllers/api/posts.go` (expose H1 setting in Show/Index payload) | 33-02 | controller | request-response | `sm-journal-plugin/controllers/api/rss.go:17-40` (settings row read) | exact |
| `sm-journal-plugin/classes/headings.go` (H1->H2 on every write path) + write-path calls in `controllers/api/posts.go`, admin save, MCP | 33-02 | utility | transform | `sm-journal-plugin/classes/format_html.go` | role-match |
| `sm-journal-plugin/console/fix_headings.go` (`journal:fix-headings --dry-run`) | 33-02 | command | batch | `sm-journal-plugin/console/export_posts.go:24-60` | exact |
| `sm-journal-plugin/assets/src/blogeditor/*.ts` (heading levels, outline, rel checkboxes, alt) | 33-02 | component | event-driven | `assets/src/blogeditor/toolbar.ts`, `extensions.ts` | exact |
| `sm-sitemap-plugin/classes/*` (writer, slots, lastmod, rebuild, indexnow, html groups) | 33-03/04 | service | file-I/O / batch | `sm-sitemap-plugin/classes/generator.go`, `serialize.go` (rewrite) | role-match |
| `sm-sitemap-plugin/filter.go` (exported `Filter`) | 33-03 | interface | request-response | `pact` capability interfaces + `app.Lookup[T]()` (see Shared) | role-match |
| `sm-sitemap-plugin/jobs.go` (`RebuildArgs`, `IndexNowArgs`) | 33-03/04 | job | batch | `sm-seo-plugin/jobs.go` + `classes/generate.go:14-28,168-177` | exact |
| `sm-sitemap-plugin/models/settings.go`, `models/{url,slot}.go` | 33-03 | model | CRUD | `sm-sitemap-plugin/models/settings.go`, `sm-seo-plugin/models/settings.go` | exact |
| `sm-sitemap-plugin/updates/*` | 33-03 | migration | - | `sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go` | exact |
| `sm-sitemap-plugin/routes.go` (index, `sitemap-<slug>-<n>.xml`, IndexNow key, HTML groups API) | 33-03/04 | route | request-response | itself, lines 30-60 | exact |
| `sm-seo-plugin/classes/sitemap_filter.go`, `registry.go`, `llms.go`, `site.go`, `autolinks.go` | 33-05/06/11 | service | request-response / CRUD | `sm-seo-plugin/classes/robots.go`, `redirects.go`, `settings_write.go` | role-match |
| `sm-seo-plugin/models/settings.go` + `fields.yaml` (`links_new_tab`, publisher, outbound lists) | 33-05/06/11 | model | CRUD | same file + `classes/settings_write.go:40-120` | exact |
| `sm-seo-plugin/models/{page,crawl,crawl_issue,autolink}.go` + `updates/*` | 33-05/10/11 | model/migration | CRUD | `sm-seo-plugin/models/redirect.go`, `updates/202610270006_create_golem15_seo_redirects.go` | exact |
| `sm-seo-plugin/controllers/api/{registry,site,crawls,autolinks}.go` | 33-05/10/11 | controller | request-response | `sm-seo-plugin/controllers/api/redirects.go`, `issues.go` | exact |
| `sm-seo-plugin/controllers/{registry,crawls,autolinks}.go` + `config_*.yaml` | 33-05/10/11 | admin screen | CRUD | `sm-seo-plugin/controllers/redirects.go` + `redirects/config_{list,form,filter}.yaml` | exact |
| `sm-seo-plugin/mcp.go`, `operations.go` (new tools, parity) | 33-05/10/11 | config | request-response | `sm-seo-plugin/mcp.go:26-52,331-343` | exact |
| `sm-seo-plugin/routes.go` (new routes) | 33-05/10 | route | request-response | itself, lines 32-77 | exact |
| `sm-seo-plugin/classes/schema/*` (@graph builder, validators) | 33-06 | service | transform | `sm-seo-plugin/classes/resolver.go`, `templates.go` | partial |
| `sm-seo-plugin/classes/crawl/*` (fetch, parse, checks, store) + crawl job in `jobs.go` | 33-10 | service/job | batch | `sm-seo-plugin/jobs.go`; fetch via `summercms.go/modules/fetchguard/fetch.go:41` | role-match |
| `sm-seo-plugin/classes/issues.go` (merge crawl issues, `h1` CMS check gated by Journal setting) | 33-10 | service | transform | itself, lines 12-60 | exact |
| `summercms.go/modules/lagoon/attach/thumb.go` (+ `webp.go`: WebP variant per thumb) | 33-11 | utility | file-I/O | itself, `ThumbKey` lines 154-243 | exact |
| WebP conga job (framework `lagoon/attach` or journal) | 33-11 | job | batch | `sm-seo-plugin/jobs.go` + `classes/settings_write.go:301-313` (enqueue after commit) | exact |
| `summercms.go/.planning/notes/2026-10-28-webp-gen2brain.md` | 33-11 | decision note | - | `.planning/notes/2026-10-07-embedded-admin-release-gate.md` | exact |
| `vue-seo-layer/lib/seo-module.ts` | 33-07/08 | config (Nuxt module) | - | `vue-journal-layer/lib/journal-module.ts:35-85` | exact |
| `vue-seo-layer/server/api/__site__/cache/purge.post.ts` + `server/lib/siteCachePurge.ts` (moved) | 33-07 | route | request-response | `vue-journal-layer/server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` | exact (move) |
| `vue-seo-layer/server/middleware/seo.ts`, `server/routes/*` (robots, sitemaps, llms, verification), `server/plugins/*` | 33-07/08 | middleware/route | request-response | `vue-journal-layer/server/routes/rss.xml.ts`, `server/plugins/journal-revalidate.ts` | role-match |
| `vue-seo-layer/app/composables/{useCmsSeo,useSchemaNode,usePagination}.ts` | 33-07/08 | hook | request-response | `vue-journal-layer/app/composables/useJournalApi.ts`, `app/utils/seo.ts`, `app/utils/pagination.ts` | role-match |
| `vue-seo-layer/app/components/Seo/{Breadcrumbs,Pagination}.vue`, HTML sitemap page | 33-08 | component | request-response | `vue-journal-layer/app/components/Journal/Pagination.vue` | role-match |
| `vue-seo-layer/testing/*`, `tests/*.test.ts` | 33-07/14 | test | - | `vue-journal-layer/testing/smoke-ssr.mjs`, `fixture-api.mjs`, `tests/seo.test.ts` | exact |
| `vue-journal-layer/lib/journal-module.ts` (`postTitleTag`, `breadcrumbs` options) | 33-09 | config | - | itself, lines 40-46 and 74-82 | exact |
| `vue-journal-layer/app/journal-pages/PostPage.vue` (title tag, breadcrumbs) | 33-09 | component | request-response | itself, line 14 and 46 | exact |
| `vue-journal-layer/app/utils/htmlGate.ts` (h1->h2 demotion following the Journal API flag) | 33-09 | utility | transform | itself, `gateJournalHtml` 268-272 | exact |
| `vue-journal-layer/app/components/Journal/PostCard.vue`, `utils/seo.ts` removal, `@nuxtjs/seo` drop | 33-09 | component | - | itself | exact |
| `vue-menu-layer/*`, `vue-faq-layer/*` (extend seo layer, delete `server/lib/siteCachePurge.ts` copies) | 33-09 | config | - | same edits as vue-journal-layer | exact |
| App changes (`sm-demo-app`, `sm-summercmsio-app`, `sm-bm-app`, `sm-grzybyfunkcjonalne-app`): go.mod, summer.yaml, nuxt.config, package.json, nginx | 33-12/13 | config | - | Phase 32 app wiring (32-05 commits in `sm-demo-app`) | role-match |
| Tests per repo | 33-14 | test | - | `sm-seo-plugin/testmain_test.go`, `classes/issues_test.go`, `settings_test.go`, `summercms.go/modules/lagoon/attach/thumb_test.go` | exact |
## Pattern Assignments
### `summercms.go/modules/pact/content.go` (seam, 33-01)
**Analog:** itself, lines 60-81. Additive optional fields only, documented with one sentence per field in the existing comment style; no SEO words (D-90).
```go
// ContentLocale is a record in one locale. URL is its public path, "" when
// the locale or the host has no public page for it. Title, Description and
// Image (a URL) are the record's own defaults for page metadata; Words is the
// body's word count, nil when the type has no body.
type ContentLocale struct {
Locale string
URL string
Title string
Description string
Image string
Words *int
}
```
Fill side: `sm-journal-plugin/classes/content_descriptors.go` `describePosts` (line 72), `firstImages` (133), `copyTime` (162).
---
### Journal H1 setting (33-02): `sm-journal-plugin/models/settings.go`, `models/settings/fields.yaml`, migration, config key, API
**Model analog** (`models/settings.go`, singleton row ID=1): add a bool column, add it to `Fillable()` and `Rules()` exactly like `rss_enabled`:
```go
RSSEnabled bool `gorm:"column:rss_enabled"`
...
func (Settings) Fillable() []string { return []string{ ..., "rss_enabled", ... } }
func (Settings) Rules() map[string]string { return map[string]string{ ..., "rss_enabled": "boolean", ... } }
```
**fields.yaml analog** (same file):
```yaml
show_all_posts:
span: left
label: golem15.journal::lang.journal.show_all_posts_label
comment: golem15.journal::lang.journal.show_all_posts_comment
type: switch
default: true
tab: golem15.journal::lang.journal.tab_general
```
Labels go in `lang/en/lang.yaml` and `lang/pl/lang.yaml`.
**Migration analog** `sm-seo-plugin/updates/202610280001_add_golem15_seo_redirects_source_key.go` (additive ALTER with Rollback, `Register(...)` in `init`):
```go
var addRedirectSourceKey = []*gormigrate.Migration{{
ID: "202610280001_add_golem15_seo_redirects_source_key",
Migrate: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE ... ADD COLUMN ...`).Error },
Rollback: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE ... DROP COLUMN IF EXISTS ...`).Error },
}}
func init() { Register(addRedirectSourceKey...) }
```
Use `DEFAULT true` so existing sites keep D-50 behaviour. Journal's own migrations live in `sm-journal-plugin/updates/` with `updates/registry.go`.
**Config key:** Journal has no `config/config.yaml` yet. Copy SEO's: `sm-seo-plugin/config/config.yaml` (commented `golem15.seo.<key>` defaults) and `plugin.go:79-80`:
```go
// ConfigFS ships config/config.yaml.
func (p *Plugin) ConfigFS() fs.FS { return configFS }
```
plus `_ pact.HasConfig = (*Plugin)(nil)` (plugin.go:26). Config read pattern: `app.Config.String(key)` as in `sm-journal-plugin/plugin.go:73-76`. Decide precedence (config key as default/override of the settings row) and document it in the README.
**Reading the row in a handler** (`controllers/api/rss.go:24-27`), with a hard default on miss:
```go
var settings models.Settings
if err := db.WithContext(r.Context()).First(&settings, 1).Error; err != nil {
settings = models.Settings{RSSLanguage: "en-us", RSSPostsPerFeed: 20, RSSEnabled: true}
}
```
Expose the flag in the public post payload of `Show` (`controllers/api/posts.go:122`) so the layer reads it from the Journal API, not a layer option.
**Command** `console/fix_headings.go`, copy `console/export_posts.go:24-60` (bonfire.Command with `Name`, `Flags` incl. `{Name: "dry-run", Bare: true}`, `Examples`, `flagOn(in, "dry-run")`, `mustDB(app)`), register in `plugin.go` `Commands()` (lines 113-118). When the setting is off it returns an error naming the setting.
---
### `sm-seo-plugin` settings additions (`links_new_tab`, publisher, outbound lists) (33-05/06/11)
**Analog:** `sm-seo-plugin/classes/settings_write.go`. Every new column must be added in four places: the model (`models/settings.go`), the allowed-columns list (lines ~36-41), `SettingsValues` (46-69, history + API), and `SetSettingsValue` switch (72+). Bool columns follow the existing `url_lowercase` case. Then `models/settings/fields.yaml` (Links tab) and the MCP `update_settings` tool picks it up automatically through `/_seo/api/v1/settings`.
```go
func SettingsValues(st models.Settings) map[string]any {
return map[string]any{
"site_name": st.SiteName,
"url_lowercase": st.URLLowercase,
...
}
}
```
Migration: same additive ALTER pattern as above, `DEFAULT true` for `links_new_tab`.
---
### `sm-seo-plugin` MCP tools (33-05/10/11): `mcp.go`, `operations.go`
**Analog:** `mcp.go:26-52`. Each tool binds to a `/_seo/api/v1` route; `Guard: mcpGuard`, `Permissions: []string{mcpPermission}`; destructive tools set `Destructive: &destructive`. `operations.go` must map each new admin operation to its tool (parity test `operations_test.go`).
```go
{
Name: "list_issues",
Title: "List SEO issues",
Description: "Lists the CMS-data SEO issues: ...; each names the tool that fixes it.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/issues",
Input: apicontrollers.ListIssuesInput{},
Permissions: []string{mcpPermission},
},
```
### `sm-seo-plugin/routes.go` (33-05/10)
**Analog:** itself, 32-77. Public, frontend-called endpoints (`/site`, route report, registry read) go in the first group wrapped in `noindex(...)` with a throttle bucket; management endpoints go in the second group with `api := "throttle:" + BucketAPI`.
```go
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
g.Get("/resolve", noindex(apicontrollers.Resolve(p.Service)), "throttle:"+BucketResolve)
g.Post("/notfound", noindex(apicontrollers.NotFound(p.Service)), "throttle:"+BucketNotFound)
})
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
api := "throttle:" + BucketAPI
g.Get("/issues", apicontrollers.ListIssues(p.Service), api)
})
```
Token-authenticated frontend POSTs (route report): copy the `notfound_token` approach (`config/config.yaml` `notfound_token` + `controllers/api/notfound.go`).
### `sm-seo-plugin/classes/issues.go` (crawler merge, 33-10)
**Analog:** itself 12-60. New kinds are constants in the same block; each `Issue` names the fixing tool (`seo_` prefix).
```go
const (
IssueMissingTitle = "missing_title"
...
)
type Issue struct {
Kind string `json:"kind"`
URL string `json:"url"`
Locale string `json:"locale,omitempty"`
Details map[string]any `json:"details,omitempty"`
Tool string `json:"tool"`
}
```
### Admin screens (registry, crawls, autolinks)
**Analog:** `sm-seo-plugin/controllers/redirects.go` + `controllers/redirects/config_{list,filter,form}.yaml`, `models/redirect.go` + `models/redirect/{columns,fields}.yaml`, registration in `controllers/admin_registry.go`, nav in `admin_navigation.go`, permission `golem15.seo.manage` in `admin_permissions.go`. Partials (`_hint.htm`) as in `controllers/notfound/`.
---
### conga jobs (sitemap rebuild, IndexNow ping, crawl, WebP variants)
**Analog:** `sm-seo-plugin/jobs.go` (whole file) and `classes/generate.go`.
```go
// generate.go
const GenerateKind = "golem15.seo.generate_entries"
type GenerateEntriesArgs struct { Type string `json:"type"` }
func (GenerateEntriesArgs) Kind() string { return GenerateKind }
// jobs.go
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{ conga.Job(p.generateEntries, conga.MaxAttempts(3)) }
}
var _ pact.HasJobs = (*Plugin)(nil)
// dispatch (generate.go:168-177)
m, err := conga.From(s.app)
return m.Dispatch(ctx, db, GenerateEntriesArgs{Type: typeKey}, conga.DispatchOpts{Label: s.Phrase("golem15.seo::lang.jobs.generate_entries", nil)})
```
Enqueue after a write commits (`classes/settings_write.go:301-313`):
```go
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, err := s.GormDB()
if err != nil { return }
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
```
Schedules: conga periodic jobs (check `summercms.go/modules/conga/conga.go` for the periodic option before planning).
### Crawler / IndexNow outbound HTTP
**Analog:** `summercms.go/modules/fetchguard/fetch.go:41` — `fetchguard.Fetch(ctx, rawURL, policy, cfg)`; redirects are never followed (3xx returned as `Result`), so the crawler records redirected links itself and re-invokes Fetch for each hop. Errors are always `*fetchguard.Error`.
---
### WebP variants: `summercms.go/modules/lagoon/attach/thumb.go` (+ new `webp.go`) (33-11)
**Analog:** `ThumbKey` (thumb.go:154-243). Hang points:
- Key: `thumbKey := PartitionDirectory(f.DiskName) + ThumbFilename(f.ID, w, h, 0, 0, mode, ext)`; the WebP variant key = same name with `.webp` appended or `ext` replaced (`ThumbFilename` coerces ext to `[a-z0-9]+`, so `"webp"` is safe). Store it next to the thumb in the same bucket.
- Encoder seam: `encodeImage` is a package var (thumb.go:119-130) switched on ext; add a `"webp"` branch calling `github.com/gen2brain/webp` (or a separate `encodeWebP` var for tests).
- Content type switch (thumb.go ~222-228): add `image/webp`.
- Error/cleanup: on encode or close error `_ = deleteKey(ctx, bucket, key)`; existence check via `bucket.Exists` makes the job idempotent.
```go
wr, err := bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: contentType})
encErr := encodeImage(wr, resized, ext)
closeErr = wr.Close()
if encErr != nil || closeErr != nil { _ = deleteKey(ctx, bucket, thumbKey); ... }
```
- Generation runs in a conga job (not in `Thumb`), dispatched when a thumb is first generated or on upload; `Thumb` callers (`cabana/field_file.go:640`, journal API) emit the WebP URL only if the variant exists (`bucket.Exists`). Tests: `lagoon/attach/thumb_test.go`.
- `go.mod` change only after the decision note in `.planning/notes/`.
---
### `vue-seo-layer/lib/seo-module.ts` and journal-layer options (33-07/08/09)
**Analog:** `vue-journal-layer/lib/journal-module.ts:35-85`. `defineNuxtModule` with `configKey`, `defaults`, and resolved options copied into `runtimeConfig.public.<key>`:
```ts
export default defineNuxtModule<JournalModuleOptions>({
meta: { name: 'vue-journal-layer', configKey: 'journal' },
defaults: { routes: { ...DEFAULT_JOURNAL_ROUTES }, perPage: {...}, sort: 'published_at desc', relatedCount: 3, rss: true },
setup(options, nuxt) {
const pub = nuxt.options.runtimeConfig.public as Record<string, any>
pub.journal = { ...(pub.journal ?? {}), routes, perPage: { ...options.perPage }, sort: options.sort, relatedCount: options.relatedCount, rss: options.rss }
},
})
```
Add `postTitleTag` (default `undefined` = follow the Journal API flag) and `breadcrumbs: true` to `defaults` and to `pub.journal`. Pages read them as `PostPage.vue:14`: `const cfg = useRuntimeConfig().public.journal as JournalModuleOptions`; title at line 46 `<h1 class="journal-post__title">` becomes `<component :is="titleTag" ...>`.
### `vue-journal-layer/app/utils/htmlGate.ts` (33-09)
**Analog:** `gateJournalHtml` (268-272). Add a pure option (e.g. `{ demoteH1: boolean }`) applied after sanitizing; pages pass the Journal API flag. Pure TS so `tests/htmlGate.test.ts` covers it with `node --test`.
```ts
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
if (!html) return { safe: true, html: '' }
const clean = sanitizeHtml(html, POLICY)
return { safe: clean === sanitizeHtml(html, KEEP_ALL), html: clean }
}
```
### Site-cache purge move (33-07, 33-09)
**Analog:** `vue-journal-layer/server/lib/siteCachePurge.ts` (byte-identical copies in `vue-menu-layer/server/lib/` and `vue-faq-layer/server/lib/`), route `server/api/__site__/cache/purge.post.ts`, plugin `server/plugins/site-cache-purge.ts`, tests `tests/sitePurge.test.ts`, `testing/purge-check.mjs`. Move them into `vue-seo-layer` unchanged, then add the resolve-LRU clear; delete the three copies in 33-09. Keep the pure-TS-outside-auto-import-dirs rule from the file header.
### Layer test harness
**Analog:** `vue-journal-layer/testing/{smoke-ssr.mjs,fixture-api.mjs,nuxt-server.mjs}` and `tests/*.test.ts` (`node --test`), `.playground/` for the module.
## Shared Patterns
### Plugin capability wiring
**Source:** `sm-seo-plugin/plugin.go` (interface assertions block at ~line 20-30), `jobs.go`, `mcp.go:10`.
**Apply to:** every new capability in sitemap/seo/journal: `var _ pact.HasX = (*Plugin)(nil)`.
### Service lookup across plugins (sitemap `Filter`, D-95)
**Source:** `sm-journal-plugin/plugin.go:98-104`:
```go
if existing, ok := app.Lookup[*cooler.SiteCachePurger](); ok && existing != nil { return nil }
if err := app.Publish(purger); err != nil { return err }
```
**Apply to:** SEO publishes its `sitemap.Filter` implementation in `Register`/`Boot`; sitemap looks it up with `app.Lookup[sitemap.Filter]()` and treats absence as "include everything".
### Settings singleton + history
**Source:** `sm-seo-plugin/classes/settings_write.go` (`SettingsRow`, `SaveSettings` 171+, history record, after-commit job).
**Apply to:** sitemap settings, SEO publisher/links settings.
### Config defaults
**Source:** `sm-seo-plugin/config/config.yaml` (commented keys, read as `golem15.<plugin>.<key>`), `ConfigFS()`.
**Apply to:** Journal H1 key, sitemap file-size/IndexNow keys, SEO route-report token.
### Frontend-facing API answers
**Source:** `sm-seo-plugin/routes.go:79-85` `noindex(h)` wrapper and throttle buckets (`Buckets()` 94+ with `surf.TrustedProxies`).
**Apply to:** `/site`, route report, HTML-sitemap groups API, IndexNow key route.
### Logging
**Source:** package-level `pluginLog`/`log` in `logger.go` of each plugin; `log.Warn("golem15.seo: ...", "error", err)`.
### Tests
**Source:** `sm-seo-plugin/testmain_test.go` (Docker Postgres, `flag.Parse()` + `testing.Short()`), `fixture_test.go`, `classes/issues_test.go`; framework `modules/lagoon/attach/thumb_test.go`.
**Apply to:** 33-14.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `sm-seo-plugin/classes/schema/*` (JSON-LD @graph, per-type validators) | service | transform | No JSON-LD code exists; use RESEARCH.md Pattern sections, resolver.go only for structure |
| `sm-seo-plugin/classes/crawl/parse.go` / checks | service | transform | No HTML-parsing crawler yet; use `golang.org/x/net/html` per RESEARCH "Don't Hand-Roll" |
| Sitemap XML writer (index/urlset/image/news, hreflang) | service | file-I/O | Current `serialize.go` is PHP byte-parity and is removed (D-32); use `encoding/xml` per RESEARCH |
| `vue-seo-layer/lib` SSR build guard + route report artefact | config | batch | No build-time Nuxt hook code in existing layers; RESEARCH.md |
| WebP encoding itself | utility | transform | New dependency `github.com/gen2brain/webp`; only the thumb plumbing has an analog |
## Metadata
**Analog search scope:** `summercms.go/modules/{pact,lagoon/attach,conga,fetchguard}`, `sm-seo-plugin`, `sm-sitemap-plugin`, `sm-journal-plugin`, `vue-journal-layer`, `vue-menu-layer/server/lib`, `vue-faq-layer/server/lib`
**Files scanned:** ~60
**Pattern extraction date:** 2026-10-28

View File

@@ -0,0 +1,879 @@
# Phase 33: SEO output, audit and content - Research
**Researched:** 2026-10-28
**Domain:** SEO output across a Go headless CMS (compiled plugins) and Nuxt 4 layers: sitemaps, structured data, hreflang, pagination, headings, breadcrumbs, SSR guarantee, crawler audit, internal linking, media
**Confidence:** MEDIUM overall. Code seams are HIGH (read this session). External specs are cited from official docs (the classify seam rates WebFetch LOW, so they are tagged `[CITED]`). Several design choices are recommendations that need Jakub's confirmation; they are listed in the Assumptions Log and Open Questions.
<user_constraints>
## User Constraints (from CONTEXT.md)
Source: `.planning/phases/33-seo-output-audit-and-content/33-CONTEXT.md`, copied verbatim. The full decision text is in `.planning/phases/32-seo-foundation/32-CONTEXT.md`, which wins when the two disagree.
### Locked Decisions
#### Planning (Jakub, 2026-10-25)
- **About 12–15 smaller plans: an exception to the lean-planning rule.** The planner must not merge them into 3–4 plans. The other project rules still apply: the plan list with a one-line scope each is shown to Jakub before PLAN.md files are written, and unit tests are the last plan.
- **Mode:** mvp (ROADMAP.md).
- **The framework still carries no SEO code (D-90).** If Phase 33 needs a new framework seam, it must be generic and additive (D-106 rules: patch tag, no breaking change); D-103 rules out a fourth seam for redirects and verification files.
#### Decisions implemented entirely in Phase 33
Section numbers refer to 32-CONTEXT.md.
| ID | Decision | 32-CONTEXT section |
|----|----------|--------------------|
| D-30 | Sitemap index split by type, 1000-URL files, real `lastmod` | 5 |
| D-31 | Image, News sitemaps, IndexNow | 5 |
| D-32 | The new format replaces PHP parity (breaking, confirmed); the XSLT todo goes with it | 5 |
| D-33 | Page registry in the CMS | 5 |
| D-34 | HTML sitemap | 5 |
| D-40 | URL form chosen per site | 6 |
| D-41 | Page 2 and later: robots, suffixes, H1, pillar text | 6 |
| D-42 | Numbered server-rendered links, rel prev/next, 301 and 404 rules | 6 |
| D-43 | One JSON-LD `@graph` | 7 |
| D-44 | Schema type, type-dependent fields, custom JSON-LD | 7 |
| D-45 | Publisher in the global settings | 7 |
| D-46 | Validation in admin, crawler and Rich Results link | 7 |
| D-50 | No H1 in body content, including Journal's conversion (D-96) | 8 |
| D-51 | Live heading outline and crawler checks | 8 |
| D-52 | One heading convention in the layers | 8 |
| D-55 | hreflang in head and sitemaps from one source | 9 |
| D-56 | `x-default` points to the default locale | 9 |
| D-57 | No hreflang for a missing translation | 9 |
| D-70 | Indexable pages always SSR or SSG; build guard; raw versus rendered check | 10 |
| D-71 | Real status codes, server-rendered links, content in HTML, image rules | 10 |
| D-72 | `sm-bm-app` gets noindex and `Disallow` only | 10 |
| D-80 | One link per post card | 11 |
| D-81 | No generic anchors | 11 |
| D-82 | Related posts, phrase auto-linking, suggestions, orphan report | 11 |
| D-83 | Outbound link rules | 11 |
| D-85 | Media SEO | 12 |
| D-86 | Breadcrumbs component and primary category | 12 |
| D-95 | `sm-sitemap-plugin` with its own filter interface that SEO implements | 0 |
| D-97 | One shared Nuxt SEO layer | 0 |
#### Decisions split between Phases 32 and 33 (Phase 33 part only)
Phase 32 delivered the rest of each; see the Phase 32 column of the decisions map.
| ID | Decision | Phase 33 part |
|----|----------|---------------|
| D-03 | Global settings | Organisation or person data for JSON-LD (with D-43, D-45) |
| D-05 | Search engine verification | Serving the files at the site root from the layer (D-103) |
| D-08 | Meta robots, `X-Robots-Tag` | Sending the header on frontend-rendered pages and files (layer, D-97) |
| D-10 | All SEO manageable through MCP on `pier` | Tools for the crawler and the other Phase 33 screens |
| D-12 | Batch tools with `dry_run` | "Noindex on a page in a sitemap" conflict refined by the D-95 filter |
| D-14 | Issue report: CMS data checks plus a crawler | The built-in crawler and its checks |
| D-20 | 404 log | The layer's call to the report endpoint |
| D-20a | Where redirects execute | Nitro middleware in the layer (D-97, D-103) |
| D-20b | URL normalisation per site | Enforcement on frontend routes through the layer |
| D-20c | Strict URLs | Layer routes reading their patterns from the CMS |
| D-21 | Default robots per page kind | Pagination page 2+ (D-41) and signed-in routes (D-23) in the layer |
| D-22 | robots.txt | The `Sitemap:` default moves to the D-30 index |
| D-23 | Private frontend pages noindex | Automatic marking of signed-in routes in the layer and the user plugin's auth layer |
| D-24 | noindex, robots.txt and sitemaps consistent | Noindex, canonicalised and redirected pages leave the sitemaps; "sitemap URL blocked" warning |
| D-25 | AI bot switches, optional llms.txt | llms.txt generated from the sitemap |
| D-93 | Content descriptors in signpost | Sitemaps and the page registry consume `Records` (D-30, D-33) |
| D-94 | `sm-seo-plugin` scope | Page registry, llms.txt, JSON-LD `@graph` and breadcrumbs APIs, crawler |
| D-96 | Content plugins keep only their own fixes | Journal's H1 to H2 conversion (D-50) and schema types (D-43) |
| D-103 | Redirect decision endpoint and verification-file API only | Layer enforcement of redirects and root mapping of verification files |
### Claude's Discretion
- HTML sitemap grouping and paging (D-34).
- Crawler implementation: Go, an in-process job through `conga`/River, politeness limits (D-14).
- Table and package names inside the plugins.
- Site-cache purge after SEO writes: Phase 32 serves effective SEO through one resolver endpoint per URL (P-15) but does not purge. Once the D-97 layer caches the resolve answer, purging through the existing site-cache purge becomes needed; plan it with D-97.
### Deferred Ideas (OUT OF SCOPE)
- From 32-CONTEXT: social card preview, per-redirect hit counters and validity dates, 307/308 redirects, e-commerce structured data.
- `sitemap-xslt-browser-deprecation.md` (g15office w8kupqi3) stays a separate backlog task; the D-32 rewrite touches the same code, so the plan should say whether the XSL output is removed or kept.
Also out of scope (33-CONTEXT domain): e-commerce structured data and shop pages; migrating `sm-bm-app` to SSR (D-72 gives it noindex and `Disallow` only).
</user_constraints>
<phase_requirements>
## Phase Requirements
No REQUIREMENTS.md IDs are assigned (ROADMAP: "Requirements: TBD"). The coverage list is the decision IDs of 33-CONTEXT. Plan numbers refer to the suggested breakdown below.
| ID | Description | Research support (section) | Suggested plan |
|----|-------------|----------------------------|----------------|
| D-30 | Index split by type, 1000-URL files, real lastmod | Sitemap rewrite: slot table, lastmod store | 33-03 |
| D-31 | Image, News sitemaps, IndexNow | Sitemap extras; specs cited | 33-04 |
| D-32 | New format replaces PHP parity; XSL decision | Sitemap rewrite; XSL removal recommended (Chrome 158) | 33-03 |
| D-33 | Page registry | SEO plugin registry + signpost type + layer route report | 33-05, 33-08 |
| D-34 | HTML sitemap | Sitemap JSON groups API + layer page | 33-04, 33-08 |
| D-40/41/42 | Pagination form, page 2+ rules, numbered links, 301/404 | Resolve answer `pagination` + layer composable | 33-06, 33-08, 33-09 |
| D-43/44/45/46 | @graph, schema type and fields, publisher, validation | Structured-data builder in SEO plugin; descriptor fields | 33-01, 33-06 |
| D-50/51/52 | No H1 in body, outline, heading convention | Journal gate + editor; layer htmlGate h1→h2; layer tests | 33-02, 33-09 |
| D-55/56/57 | hreflang head + sitemap, x-default, missing translations | Resolve `alternates`; sitemap xhtml:link | 33-03, 33-06, 33-07 |
| D-70 | SSR/SSG only for indexable routes; build guard; raw vs rendered | Nuxt module guard; crawler raw-HTML check | 33-08, 33-10 |
| D-71 | Real status codes, server links, content in HTML, image rules | Layer middleware + components; crawler checks | 33-07, 33-09, 33-10 |
| D-72 | sm-bm-app noindex + Disallow | Static robots.txt + meta + header | 33-12 |
| D-80/81 | One link per card, no generic anchors | PostCard change; editor warning; crawler check | 33-02, 33-09, 33-10 |
| D-82 | Related posts, auto-links, suggestions, orphan report | Autolink table + layer transform; crawler link graph | 33-11, 33-09 |
| D-83 | Outbound link rules | Editor rel checkboxes; domain list; layer transform | 33-02, 33-11, 33-09 |
| D-85 | Media SEO | Editor alt/decorative; missing-alt report; bulk alt tool; formats decision | 33-02, 33-11 |
| D-86 | Breadcrumbs + primary category | Resolve `breadcrumbs`; layer component | 33-06, 33-08 |
| D-95 | Sitemap filter interface implemented by SEO | `app.Publish[sitemap.Filter]` | 33-03, 33-05 |
| D-97 | One shared Nuxt SEO layer | New `vue-seo-layer` repo | 33-07, 33-08 |
| D-03 (33 part) | Organisation/person data | Publisher settings | 33-06 |
| D-05 (33 part) | Verification files at site root | Layer root mapping | 33-07 |
| D-08 (33 part) | X-Robots-Tag on frontend pages and files | Layer middleware header | 33-07 |
| D-10 (33 part) | MCP tools for Phase 33 screens | `AdminOperations` parity test extended | 33-05, 33-10, 33-11 |
| D-12 (33 part) | Batch conflict refined by filter | `noindex_published` conflict reads the filter | 33-05 |
| D-14 (33 part) | Crawler | conga job, fetchguard, x/net/html | 33-10 |
| D-20, D-20a/b/c (33 part) | 404 report call, redirect middleware, normalisation, strict URLs | Layer middleware; route drift check | 33-07, 33-08 |
| D-21, D-23 (33 part) | Page 2+ robots; signed-in routes noindex | Layer route meta + middleware | 33-08 |
| D-22 (33 part) | `Sitemap:` → index | Index stays at `/sitemap.xml` | 33-03, 33-05 |
| D-24 (33 part) | Sitemaps exclude noindex/canonicalised/redirected; "sitemap URL blocked" | Filter + issue kind | 33-05 |
| D-25 (33 part) | llms.txt from the sitemap | SEO route reading sitemap URL set | 33-05 |
| D-93 (33 part) | Sitemaps and registry consume Records | Rebuild job over `signpost.Records` | 33-03 |
| D-94 (33 part) | Registry, llms.txt, @graph, breadcrumbs APIs, crawler | SEO plugin plans | 33-05, 33-06, 33-10 |
| D-96 (33 part) | Journal H1→H2, schema types | Journal plan + descriptor kind | 33-02 |
| D-103 (33 part) | Layer enforcement of redirects, root verification files | Layer middleware | 33-07 |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
From `summercms.go/CLAUDE.md`, `../CLAUDE.md` and the user's global instructions. These carry the authority of locked decisions.
- **No co-author tags in commits.** One logical change per commit; planning docs and code in separate commits. Stage files by explicit path, never `git add -A`; several sessions work concurrently (leave other sessions' uncommitted `STATE.md`, `state.json`, submodule drift alone).
- **Don't push, tag or deploy without Jakub's go-ahead.** Every pushed framework release tag `vX.Y.Z` gets a changelog post on summercms.io the same day (`../sm-summercmsio-app/scripts/changelog-post.py`; never print, copy or mint the `g15_` token).
- **Framework code never names a consuming application**, and (D-90) never names SEO. New framework seams are generic and additive (D-106: patch tag).
- **Dependencies: standard library first.** A new Go or npm dependency needs a research doc or a decision note naming it (`.planning/research/STACK.md` holds the decided set). YAML via `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
- **Docs rules:** a change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages in the same commit; the docs checker (`summer docs:build --check`, `TestDocsTree`) enforces identifiers. Plugin READMEs follow the standard structure.
- **Core plugins live in their own repos**, mounted in apps as submodules at `plugins/<vendor>/<name>`; commit and push the plugin first, then the pointer bump in the app as a separate commit. Consumers `require` a tagged framework version and `replace` it with `../summercms.go` during development.
- **Core plugins (user, blog, pages, payment) work across many projects: avoid breaking changes unless asked.** D-32 is the explicitly confirmed breaking change for `sm-sitemap-plugin`; no other breaking change is authorised.
- **Porting rule:** the existing frontend's requests define the contract; response shapes are not "improved". Phase 33 adds fields; it must not change existing Journal/SEO answer shapes.
- **Tests:** keep `go vet ./...` and `go test ./...` green at every commit; Docker tests skip under `-short` via `testing.Short()` after `flag.Parse()`.
- **MSD rules:** plan list with one-line scopes shown to Jakub before PLAN.md files; unit tests are the last plan; STATE.md only through `msd_run query state.*`; g15office comment when the phase advances.
- **Memory:** prefer the maintained library plus a decision note for protocol and security code; additive framework changes are a patch tag (0.5.x), never hard-code the version; create new repos with `tea`.
## Summary
Phase 32 left a working backend (sm-seo-plugin v0.1.0: resolver, redirects, robots.txt, MCP, CMS-data issues) and three framework seams, but nothing on the frontend consumes them and the sitemap plugin is still the Phase 17 PHP-parity single file. Phase 33 spans **ten repositories**: framework, four Go plugins (sitemap, seo, journal, optionally faq), a new Nuxt layer plus three existing layers, and four apps. The dominant planning problem is therefore **release ordering across repos**, not any single technique: framework tag → Journal → sitemap → SEO → `vue-seo-layer` → journal/menu/FAQ layers → apps. Apps pin layers by git tag (pnpm) and plugins by Go tag, so app plans either wait for tags or develop against local links and re-pin in the final plan.
Two prerequisites block a clean start. (1) Phase 32's post-review fixes are **unreleased**: framework is 18 commits past v0.5.1 (CR-01, CR-02, CR-04, WR-01, WR-02, WR-07), sm-seo-plugin 21 commits past v0.1.0 (CR-03 plus a new migration), Journal 3 commits past v0.2.5. 32-UAT test 3, the release decision, is still pending. (2) The D-93 descriptor (`pact.ContentRecord` / `ContentLocale`) lacks what JSON-LD, breadcrumbs, the image sitemap and the News sitemap need: a publication date, a parent trail, authors, body images and a content kind. Phase 33 therefore needs one more **generic, additive** framework seam. It can ship in the same v0.5.2 patch as the unreleased fixes.
Technically the phase splits into well-understood pieces:
- **XML sitemaps** are a stored-slot rewrite: a persistent URL→file-number table gives stable numbering, and record events give real `lastmod`.
- **The resolver** gains additive answer fields: `alternates`, `jsonld`, `breadcrumbs`, `pagination`, `autolinks`.
- **The new `vue-seo-layer`** renders that answer with Nuxt core `useHead`/`useSeoMeta`, enforces redirects and 410 in a Nitro middleware, proxies root files to Go, and fails the build on an indexable `ssr: false` route.
- **The crawler** is a conga job on the existing `fetchguard` client, parsing HTML with `golang.org/x/net/html`.
The riskiest items are product decisions, not code:
- D-70 asks for a raw-versus-rendered comparison, which needs a headless browser.
- D-85 asks for WebP/AVIF, which needs a new dependency.
- Success criterion 3 says every crawler issue is fixable through MCP, which cannot hold for layout bugs.
- grzybyfunkcjonalne.pl moves a live production site several versions at once.
**Primary recommendation:**
- Start with a Wave-1 plan that ships the additive descriptor seam together with the pending Phase 32 fixes as framework v0.5.2.
- Build `vue-seo-layer` without `@nuxtjs/seo` and have journal, menu and FAQ extend it.
- Keep `/sitemap.xml` as the index URL so GSC and robots.txt need no URL change.
- Drop the XSL output: Chrome stops supporting XSLT on 2026-11-17.
## Architectural Responsibility Map
| Capability | Primary tier | Secondary tier | Rationale |
|------------|-------------|----------------|-----------|
| Sitemap index, per-type files, image/News, IndexNow pings | API/Backend (`sm-sitemap-plugin`, Go) | Frontend server (proxy of `/sitemap*.xml` when nginx does not route them) | D-95: sitemap owns generation from signpost; Go has the records and events |
| Exclusion of noindex/canonicalised/redirected URLs from sitemaps | API/Backend (`sm-seo-plugin` implements `sitemap.Filter`) | — | D-95: sitemap knows nothing about SEO |
| Page registry (D-33) | API/Backend (`sm-seo-plugin`, signpost type) | Frontend server (reports routes at server start) | Registry is CMS data; Nuxt knows its static routes |
| Effective SEO, JSON-LD @graph, breadcrumbs, hreflang, pagination metadata | API/Backend (resolver) | Frontend server (renders into `<head>`, adds layer-owned nodes such as FAQPage) | D-43/D-94: graph generated in CMS; one resolver call per URL (P-15) |
| Redirect/410 enforcement, X-Robots-Tag on pages | Frontend server (Nitro middleware in `vue-seo-layer`) | API/Backend (decision) | D-20a/D-103: CMS decides, the serving tier enforces |
| Verification files, robots.txt, llms.txt, IndexNow key file at site root | API/Backend (served by Go) | Frontend server (root proxy) | D-103: no fourth framework seam; the layer maps the root |
| SSR build guard | Frontend build (Nuxt module in `vue-seo-layer`) | — | Only the Nuxt build knows routeRules and `.client.vue` pages |
| Visible breadcrumbs, pagination links, heading convention, one link per card | Browser/Client markup rendered by Frontend server (SSR) | — | D-71: all navigation server-rendered `<a href>` |
| H1→H2 conversion of stored content | API/Backend (Journal write gate) | Frontend server (htmlGate demotes h1 as defence in depth) | D-50/D-96: the content plugin fixes its own data |
| Phrase auto-links, outbound rel rules | Frontend server (body HTML transform at SSR) | API/Backend (phrase table and domain list in resolve answer) | Keeps Journal unaware of SEO (D-96); output is server-rendered HTML |
| Crawler, issue report, orphan report, link suggestions | API/Backend (`sm-seo-plugin` conga job) | — | D-14: Go, in-process job |
| Media alt / decorative / file-name slug in the editor | API/Backend + admin element (Journal editor) | — | The editor and its media store are Journal's |
| WebP/AVIF/srcset generation | Undecided (see Open Question 3) | — | Needs a new dependency in Go, or a Nuxt image pipeline |
## Prerequisites (resolve before or in the first plan)
1. **Unreleased Phase 32 fixes.** Framework `git log v0.5.1..HEAD` lists the fixes CR-01 (`676ca53`), CR-02 (`5471b66`), CR-04 (`d660cd0`), WR-01 (`468af06`), WR-02 (`1583651`) and WR-07 (`0e187cf`). sm-seo-plugin has 21 commits after v0.1.0, including CR-03 (`2d81c26`) and migration `202610280001_add_golem15_seo_redirects_source_key`. sm-journal-plugin has 3 commits after v0.2.5 [VERIFIED: git log in each repo, this session]. 32-UAT test 3 (release or hold) and test 4 (the meaning of `RecordSaved.Locale`, WR-02) are pending. Phase 33 builds on tags (Q-01). Recommendation: fold the release of these fixes into 33-01, together with the new additive seam, as framework **v0.5.2**, sm-seo-plugin **v0.1.1** and Journal **v0.2.6** or later. The exact numbers are read from `git tag` at release time.
2. **UI design contract.** `.planning/config.json` has `workflow.ui_phase: true`. Phase 33 adds admin screens: crawler runs and issues, page registry, auto-links, sitemap settings, publisher and schema fields in the SEO tab. It also adds layer components: breadcrumbs, pagination, HTML sitemap. A `33-UI-SPEC.md` following 32-UI-SPEC conventions (E-elements, C-contracts) is expected before plans that build these screens [VERIFIED: .planning/config.json read via shell].
3. **Release cadence decision (Open Question 1).** Can intermediate tags be pushed mid-phase, so app plans pin real tags? Or do app plans work against local links and the final plan tags and re-pins everything, as Phase 32 did with `32-05-demo-mount.patch`?
## Standard Stack
No new runtime dependency is required for the recommended MVP scope. Every item below is already in the decided set or already in the repos.
### Core (Go)
| Library / module | Version | Purpose | Why standard |
|---------|---------|---------|--------------|
| SummerCMS `signpost` | v0.5.1 + additive v0.5.2 fields | `ContentTypes`, `Records`, `Describe`, `MatchPath` feed sitemaps, registry, resolver | D-93 seam [VERIFIED: modules/signpost/content.go func list] |
| SummerCMS `conga` | framework | Sitemap rebuild, IndexNow ping, crawl jobs; `pact.HasSchedule` for nightly runs (`conga.Daily`, `conga.Every`) | Decided job system [VERIFIED: modules/conga/README.md] |
| SummerCMS `fetchguard` | framework | Crawler and IndexNow HTTP: no redirect following, body cap, timeout, host allow-list | Framework SSRF guard [VERIFIED: modules/fetchguard/README.md] |
| SummerCMS `cooler` | framework | `cooler.PurgeSiteCacheAfterCommit` after SEO writes (keys `site_cache.purge_url`, `site_cache.purge_token`) | Existing site-cache purge [VERIFIED: modules/cooler/sitecache.go:26,29] |
| `golang.org/x/net/html` | already required by sm-journal-plugin | Crawler HTML parsing, auto-link/heading transforms in Go | x/ family, already in the dependency graph [VERIFIED: sm-journal-plugin/classes/format_html.go imports `xhtml "golang.org/x/net/html"`] |
| `github.com/jimsmart/grobotstxt` | v1.0.3 | "Sitemap URL blocked" and crawler robots checks | Decided D-99 [VERIFIED: .planning/research/STACK.md line 50] |
| stdlib `encoding/xml` | Go 1.27 | Sitemap XML escaping (`xml.EscapeText`) now that byte parity with PHP is dropped | Standard library first |
| stdlib `encoding/json` | — | JSON-LD graph building and custom JSON-LD validation | Standard library first |
### Core (Nuxt)
| Library | Version | Purpose | Why standard |
|---------|---------|---------|--------------|
| `nuxt` | 4.4.8 (pinned across layers) | `useHead`/`useSeoMeta` (core unhead), routeRules, Nitro middleware, module hooks | Existing pin [VERIFIED: vue-journal-layer/package.json] |
| `unhead` | 2.1.17 (via Nuxt) | Escapes `<` in JSON script `innerHTML` (`<`), so JSON-LD cannot break out of `<script>` | [VERIFIED: node_modules/unhead/dist/shared/unhead.BrXkGDAU.mjs:157-161] |
| `h3` | 1.15.11 (via Nitro 2.13.4) | `sendRedirect` from server middleware ends the stack (`event.handled` check) | [VERIFIED: node_modules h3 dist/index.mjs `createAppEventHandler`, read this session] |
| `@nuxtjs/i18n` | 10.4.0 | Locale routing stays in journal layer; do **not** use its hreflang head (would emit hreflang for missing translations, D-57) | Existing pin |
| `sanitize-html` | 2.18.0 | Layer htmlGate; `transformTags` for h1→h2, `textFilter` receives escaped text and its output is appended unescaped | [VERIFIED: node_modules sanitize-html/index.js:340-344, 691-696] |
### To remove from the layers (replace, do not wrap)
| Package | Current | Why remove |
|---------|---------|-----------|
| `@nuxtjs/seo` | 5.2.1 in vue-journal-layer (`modules: [..., '@nuxtjs/seo', ...]`), and summercms.io | It installs `nuxt-schema-org`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `nuxt-seo-utils`, `nuxt-site-config` and others. Schema.org adds default WebSite and WebPage nodes automatically, a second graph that conflicts with D-43. robots and sitemap would compete with Go's `/robots.txt` and `/sitemap.xml`. seo-utils emits its own canonical. [VERIFIED: vue-journal-layer/nuxt.config.ts:21; node_modules/@nuxtjs/seo/package.json dependencies] [CITED: nuxtseo.com/docs/schema-org/guides/default-schema-org] |
Latest `@nuxtjs/seo` is 6.0.1 [VERIFIED: npm view]. Upgrading is moot if the layer drops it.
### Alternatives considered
| Instead of | Could use | Tradeoff |
|------------|-----------|----------|
| Core `useHead` + CMS graph | `nuxt-schema-org` with `defaults: false` fed from the CMS | Still a second graph builder and a dependency; D-43 wants the CMS to generate the graph |
| x/net/html crawler | `goquery`, `colly` | New dependencies; x/net/html is enough for links, headings, meta, JSON-LD scripts and images |
| Raw-HTML-only crawl | `chromedp` + Chromium for rendered DOM | Real D-70 rendered comparison, but Chrome on every server and a new dependency (Open Question 2) |
| Stored slot table | Generate on request with cache (today's design) | Cannot give stable numbering when records are deleted; costly for large sites |
**Installation:** none for the MVP scope. Optional, only after a decision note: `github.com/gen2brain/webp`, `github.com/gen2brain/avif` (Go, WASM via wazero) or `github.com/chromedp/chromedp`. All are `[ASSUMED]`; see the audit below.
**Version verification:**
- `npm view @nuxtjs/seo version` → 6.0.1.
- Go proxy `@latest`: gen2brain/webp v0.6.4 (2026-07-01), gen2brain/avif v0.6.0 (2026-07-05), chromedp v0.21.0 (2026-10-11) [VERIFIED: proxy.golang.org, this session].
## Package Legitimacy Audit
The MVP scope installs **no new package**. The table covers the optional candidates, in case Jakub chooses them (Open Questions 2 and 3). The `msd-tools package-legitimacy` seam supports only npm, pypi and crates, so the Go modules were checked against proxy.golang.org only.
| Package | Registry | Age | Downloads | Source repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| github.com/gen2brain/webp | Go proxy | v0.1.0 2024-03-06, 31 versions | n/a | github.com/gen2brain/webp | not run (seam has no Go support) | `[ASSUMED]`: planner adds `checkpoint:human-verify` + decision note before install |
| github.com/gen2brain/avif | Go proxy | 29 versions, latest 2026-07-05 | n/a | github.com/gen2brain/avif | not run | `[ASSUMED]`: same |
| github.com/chromedp/chromedp | Go proxy | v0.1.0 2018-07-03, 82 versions | n/a | github.com/chromedp/chromedp | not run | `[ASSUMED]`: same; also requires Chromium on servers |
**Packages removed due to [SLOP] verdict:** none.
**Packages flagged as suspicious [SUS]:** none. All three optional Go modules are `[ASSUMED]` and each is gated behind a human checkpoint plus a `.planning/notes/` decision note and a STACK.md row (project rule).
## Architecture Patterns
### System architecture diagram
```
┌───────────────────────── Build time (Nuxt) ─────────────────────────┐
app nuxt.config ──► vue-seo-layer module: collect pages + routeRules ──► ssr:false on a route
not declared noindex? ──► FAIL BUILD (D-70) not declared noindex
emit static route list (seo-routes.json) ─────────────────┐
└─────────────────────────────────────────────────────┼──────────────┘
│ (server start)
Visitor/Googlebot ▼
│ GET /any/path POST /_seo/api/v1/registry/routes (token)
▼ │
nginx ─┬─ /sitemap*.xml, /robots.txt, /llms.txt, /<key>.txt ──────────► Go binary
│ (or Nuxt proxies them) │
└─ everything else ──► Nuxt (Nitro) │
│ │
server/middleware/seo.ts (skips /_nuxt, /api, assets) │
│ GET /_seo/api/v1/resolve?url= ── LRU (TTL, purged) ──────► sm-seo-plugin Resolver
│ │ 1 normalise (D-20b, ?page=1, page form D-40)
├─ action=redirect ─► 301/302 Location (D-20a) │ 2 redirects 3 strict URL (D-20c)
├─ action=gone ─────► 410 │ 4 SEO precedence (Phase 32)
└─ ok/not_found ──► event.context.seo, X-Robots-Tag header │ 5 NEW: alternates, jsonld @graph,
│ │ breadcrumbs, pagination, autolinks
Vue SSR render (route cache swr) │ ▲ signpost.Describe (+new fields)
useCmsHead(): title/meta/canonical/hreflang/html lang/ │ ▲ settings (publisher, D-45)
og:locale/one JSON-LD script (CMS @graph + layer nodes) │ ▲ entry data (schema type, D-44)
<SeoBreadcrumbs> <SeoPagination> PostCard (one link) │
PostBody htmlGate: h1→h2, auto-links, outbound rel │
│ 404 page ─► POST /_seo/api/v1/notfound (D-20) ──►│
▼ │
HTML │
│
Admin/MCP write ─► cabana save ─► RecordSaved/Deleted (after commit) ─────────┤
├─► sitemap: lastmod store + debounced rebuild job (conga)
│ └─ signpost.Records per type ─► sitemap.Filter (SEO) ─► slot table
│ └─ diff of URL set ─► IndexNow ping job (D-31)
└─► SEO: cooler.PurgeSiteCacheAfterCommit ─► Nuxt purge route clears
route cache + resolve LRU
Crawler job (on demand / schedule) ─► fetch /sitemap.xml index + files ─► fetch pages (fetchguard,
same host, concurrency 2) ─► parse (x/net/html) ─► checks ─► golem15_seo_crawl_* tables ─► issues API + MCP
```
### Recommended repository layout (new and changed parts)
```
sm-sitemap-plugin/
├── classes/ # writer (index, urlset, image, news), slots, lastmod, rebuild, indexnow, html groups
├── filter.go # exported Filter interface + Candidate/Decision (D-95), looked up via app.Lookup
├── jobs.go # RebuildArgs, IndexNowArgs (conga)
├── models/ # settings (site URL, per-type file slug + include, file size, news, indexnow), urls, slots
└── updates/ # new migrations; old definitions table kept for url items (custom pages group)
sm-seo-plugin/
├── classes/schema/ # @graph builder, per-type validators (D-43..D-46)
├── classes/crawl/ # crawler: fetch, parse, checks, store (D-14, D-51, D-70, D-71, D-81..D-83, D-85)
├── classes/registry.go, sitemap_filter.go, autolinks.go, llms.go, site.go (layer site-config answer)
└── controllers/ # registry, crawls, autolinks (+ api/ and mcp.go tools, operations.go parity)
vue-seo-layer/ # NEW repo (tea), name to confirm (D-97)
├── lib/seo-module.ts # SSR build guard, route report artefact, options (configKey `seo`)
├── server/middleware/seo.ts # resolve, redirect/410, X-Robots-Tag, 404 report hook
├── server/routes/ # root proxies: robots.txt, sitemap*.xml, llms.txt, verification, indexnow key
├── server/api/__site__/cache/purge.post.ts + server/lib/siteCachePurge.ts # moved here from journal/menu/faq
├── server/plugins/ # route report to registry at start; resolve LRU purge hook
├── app/composables/ # useCmsSeo, useSchemaNode, usePagination
├── app/components/Seo/ # Breadcrumbs.vue, Pagination.vue, HtmlSitemap page
├── testing/ # smoke-ssr harness + fixture /_seo/api/v1
└── tests/*.test.ts # node --test on pure TS utils
```
### Pattern 1: Additive descriptor fields (framework, generic)
**What:** extend `pact.ContentRecord` / `pact.ContentLocale` / `pact.ContentDescriptor` with optional fields that name nothing SEO-specific. They are useful to search indexers, feeds and audit tools as well.
**Today** [VERIFIED: modules/pact/content.go:63-81]:
```go
type ContentRecord struct {
ID string
Published bool
LastMod *time.Time
Locales []ContentLocale
}
type ContentLocale struct {
Locale string
URL string
Title string
Description string
Image string
Words *int
}
```
**Recommended additions** `[ASSUMED]` names; the shape needs Jakub's confirmation at a blocking checkpoint, as 32-01/T1 and 32-02/T1 did:
```go
// ContentRecord (additive)
PublishedAt *time.Time // first publication (datePublished, News publication_date)
Parents []ContentRef // ordered candidate parents (first = default primary): breadcrumbs, articleSection
Authors []ContentPerson // name + optional public URL (Article author)
// ContentLocale (additive)
Images []ContentImage // featured + body images: URL, Alt (image sitemap D-31, missing-alt report D-85)
Videos []ContentVideo // e.g. provider "youtube" + id (VideoObject, D-43)
// ContentDescriptor (additive)
Kind string // "article" | "collection" | "profile" | "page" (default schema mapping; D-96 "schema types")
```
**Why:** without these, the SEO plugin would have to import Journal, which D-96 forbids, or the layer would have to build JSON-LD, which contradicts D-43. Journal currently fills only the existing fields, and `LastMod` is `UpdatedAt` [VERIFIED: sm-journal-plugin/classes/content_descriptors.go:97-101].
**Constraint:** check-phase32's `additive` stage logic applies. Fields may be added; no interface may gain a method.
### Pattern 2: Stable sitemap numbering via a slot table
**What:** persist each URL's file assignment. `golem15_sitemap_urls (group, type_key, record_id, locale, url, file_no, first_seen, lastmod, content_hash, images jsonb, alternates jsonb, excluded_reason)`. A rebuild job:
1. walks `signpost.Records` per content type (page size ≤ 500; Journal caps at 500);
2. asks `sitemap.Filter` per candidate;
3. keeps existing rows' `file_no`;
4. appends new URLs into the last file with room, ordered by first-seen time (oldest first, D-30);
5. marks vanished ones excluded, leaving a hole rather than shifting later files.
The routes serve from the table, so there is no per-request generation.
**When:** always (D-30). File size comes from a setting: default 1000, maximum 50 000.
**lastmod:** a `pact.RecordSaved` listener sets `lastmod = now` for rows of that record when `len(e.Changes) > 0`. A no-op save fires `RecordSaved` with an empty `Changes` list (32-02-SUMMARY key decision). The descriptor's `LastMod` is used only for the initial value. An ignore list of attribute names (config) keeps counters out.
### Pattern 3: Sitemap filter interface (D-95)
```go
// in sm-sitemap-plugin (package sitemap), [ASSUMED] shape
type Candidate struct{ TypeKey, RecordID, Locale, URL string }
type Decision struct {
Exclude bool
Reason string // "noindex" | "canonical" | "redirect" | ...
Group string // optional file-group override (page registry kinds: page/offer/service/landing)
LastMod *time.Time // optional override
}
type Filter interface {
Decide(ctx context.Context, batch []Candidate) ([]Decision, error) // batched: one resolve pass per page of records
}
// sm-seo-plugin Register: app.Publish[sitemap.Filter](seoFilter{...}); Requires() includes "golem15.sitemap"
// sm-sitemap-plugin: f, ok := app.Lookup[sitemap.Filter]() — absent → include everything
```
`backpack.Registry.Publish[T]` keys by the type argument, so publishing under the interface type works. A duplicate `T` is an error [VERIFIED: modules/backpack/services.go:22-41]. `Group` exists because the page registry must be **one** signpost type: `signpost` refuses two types with the same `Model` or `Controller` [VERIFIED: modules/signpost/content.go addContent], yet D-30/D-33 split page/offer/service/landing into separate files.
### Pattern 4: Resolve answer grows additively; layer renders it
Phase 32 answer fields [VERIFIED: sm-seo-plugin/classes/resolver.go:55-101]:
- `Result`: `action`, `status`, `location` (omitempty), `url`, `locale`, `staging`, `seo`.
- `SEO`: `title`, `description`, `canonical`, `robots`, `x_robots_tag`, `og` {`title`, `description`, `image`, `url`}, `twitter` {`card`, `site`}, `verification` [{`name`, `content`}].
Phase 33 adds `[ASSUMED]` names (no existing key changes):
- `alternates` [{hreflang, url}] plus `x_default`, `html_lang`, `og_locale`, `og_locale_alternates` (D-55..57);
- `jsonld` {"@context", "@graph"} (D-43);
- `breadcrumbs` [{name, url}] (D-86);
- `pagination` {param or path form, page, title/description suffix templates, `pillar_on_first_only`} (D-40/41);
- `autolinks` [{phrase, url}] and `outbound` {nofollow_domains, sponsored_domains} (D-82/83).
The layer may append its own nodes (FAQPage from the FAQ layer) through `useSchemaNode()`, so one `<script type="application/ld+json">` holds one `@graph`.
### Pattern 5: Nitro middleware enforces the decision; page cache stays valid
```ts
// vue-seo-layer/server/middleware/seo.ts — sketch
export default defineEventHandler(async (event) => {
const path = event.path
if (!isPagePath(path)) return // skip /_nuxt, /api, /__nuxt_error, files with extensions, Go-routed paths
const res = await resolveCached(event, path) // LRU keyed by path+query, TTL ~60 s, cleared by the purge route
if (!res) return // CMS unreachable or no SEO plugin: render normally (fail open)
if (res.action === 'redirect') return sendRedirect(event, res.location, res.status)
if (res.action === 'gone') throw createError({ statusCode: 410, statusMessage: 'Gone' })
if (res.seo?.x_robots_tag) setResponseHeader(event, 'x-robots-tag', res.seo.x_robots_tag)
event.context.seo = res // the app plugin reads it during SSR; no second fetch
})
```
Middleware runs before Nitro's cached route handler, so redirects and 410 apply even to swr-cached pages. h3 stops the stack once a middleware handles the event [VERIFIED: h3 1.15.11 createAppEventHandler]. Nuxt's docs say middleware "should not return anything"; the redirect early-return is h3 behaviour, so the planner adds a smoke check `[CITED: nuxt.com/docs/4.x/directory-structure/server]`.
`not_found` is **not** enforced by the middleware. Nuxt's own route matching and the pages (`createError 404`) decide 404. A Nuxt page without a registry entry would otherwise 404 until registered.
### Pattern 6: SSR build guard (Nuxt module)
```ts
// lib/seo-module.ts — sketch
setup(options, nuxt) {
nuxt.hook('pages:resolved', (pages) => { collected = flatten(pages) }) // page.mode === 'client' for .client.vue
nuxt.hook('nitro:config', (nitro) => {
const rules = { ...nuxt.options.routeRules, ...nitro.routeRules }
const offenders = []
if (nuxt.options.ssr === false && options.indexable !== false) offenders.push('<global ssr:false>')
for (const [pattern, rule] of Object.entries(rules))
if (rule?.ssr === false && !isNoindex(pattern, rule, options.noindexRoutes)) offenders.push(pattern)
for (const p of collected) if (p.mode === 'client' && !isNoindex(p.path, undefined, options.noindexRoutes)) offenders.push(p.path)
if (offenders.length) throw new Error(`[vue-seo-layer] indexable routes must render on the server (D-70): ${offenders.join(', ')}`)
// declared noindex routes get X-Robots-Tag automatically:
for (const pattern of options.noindexRoutes) nitro.routeRules![pattern] = defu(nitro.routeRules![pattern], { headers: { 'x-robots-tag': 'noindex, nofollow' } })
})
}
```
- routeRules `ssr: false` disables SSR for matching routes [CITED: nuxt.com/docs/4.x/guide/concepts/rendering].
- A `.client.vue` page "None of the content of this page will be rendered on the server" [CITED: nuxt.com/docs/4.x/directory-structure/app/pages].
- `defineRouteRules` is experimental behind `experimental.inlineRouteRules` [CITED: nuxt.com/docs/4.x/api/utils/define-route-rules]. The guard should refuse that flag, or read the generated rules.
- Real test case: the demo app has `'/p/**': { ssr: false }` and `'/presentations/**': { ssr: false }` [VERIFIED: sm-demo-app/vue-demo-app/nuxt.config.ts:141-144]. These must be declared noindex, or the demo build fails by design.
- Hybrid rendering is not available with `nuxt generate` [CITED: nuxt.com rendering docs], so summercms.io (SSG) passes the guard trivially.
### Pattern 7: Crawler as a conga job
- **Trigger:**
- admin button and MCP tool `seo_start_crawl` → `conga.Manager.Dispatch` with a `summer_jobs` record, so progress is shown;
- `pact.HasSchedule` entry `seo:crawl` (for example `conga.Daily`).
- **Seeds:** the home page plus `/sitemap.xml`, fetched over HTTP so the real output is audited; then same-host links.
- **Limits** (config): `max_pages` (default 5000), concurrency 2, delay 250 ms, per-request timeout 10 s, body cap 5 MiB, UA `SummerCMS-SEO-Crawler/1 (+<site_url>)`.
- **Client:** a `fetchguard.Client` built in Go from `golem15.seo.site_url`. Guarded modes are HTTPS-only and block private IPs, so a staging or local http site needs `TrustedMode`, which only Go code can choose [VERIFIED: modules/fetchguard/README.md Features]. Redirects are never followed, so 3xx hops are observed as they are.
- **Storage:** `golem15_seo_crawls`, `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_crawl_issues`. Keep the last N runs.
- **Issues** reuse the Phase 32 `Issue` shape, `{kind, url, locale, details, tool}`, with `tool` naming the fixing MCP tool [VERIFIED: sm-seo-plugin/classes/issues.go:28-42, e.g. `toolSetURLSEO = "seo_set_url_seo"`].
- **Staging:** when `Staging()` is true, noindex caused by the staging lock is not an issue.
### Anti-patterns to avoid
- **Keeping `@nuxtjs/seo` and "turning off parts":** schema-org adds WebSite and WebPage by default, robots emits meta and `/robots.txt`, seo-utils emits canonical. That gives two graphs and two canonicals, and the module would need re-auditing on every bump.
- **Using `@nuxtjs/i18n`'s locale head for hreflang:** it lists every enabled locale, violating D-57.
- **Generating sitemaps per request from `Records`:** slow on large sites and unstable numbering.
- **Treating `resolve.not_found` as 404 in the middleware:** it 404s legitimate Nuxt pages not yet in the registry.
- **Linking phrases with regex over raw HTML:** links end up in attributes and headings. Walk the sanitized output's text nodes with an open-tag stack (Code Examples).
- **Calling resolve on every request without a cache:** bucket `seo-resolve` allows 600/min per client IP behind trusted proxies [VERIFIED: sm-seo-plugin/routes.go:14-23 and Buckets()]. The Nuxt server must forward `X-Forwarded-For`.
## Suggested Plan Breakdown (14 plans, per-repo aware)
Each plan names its repos and what must be tagged before dependents can pin it. "Tag" means a release tag pushed with Jakub's go-ahead (Open Question 1 decides whether tags happen inside each plan or all in 33-14).
| # | Scope (one line) | Repos | Depends on | Tags produced |
|---|------------------|-------|-----------|---------------|
| 33-01 | Additive descriptor seam (`PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind`) + signpost docs + `check-phase33.sh` skeleton; release the pending Phase 32 fixes with it | summercms.go | — (blocking Go-shape checkpoint) | framework v0.5.2 |
| 33-02 | Journal: fill the new fields, H1→H2 on every write path with a save notice + `journal:fix-headings` dry-run command (D-50), editor heading levels 2–4 + live outline (D-51), link rel checkboxes + policy, generic-anchor warning (D-81/83), image alt required / decorative, slug file names (D-85) | sm-journal-plugin | 33-01 | Journal v0.2.x |
| 33-03 | Sitemap rewrite: index at `/sitemap.xml`, per-type `sitemap-<slug>-<n>.xml`, slot table, lastmod store, file size setting, `Filter` interface, hreflang `xhtml:link`, url-item definitions → pages group, rebuild job + schedule; remove PHP parity, tiers, goldens, XSL | sm-sitemap-plugin | 33-01 | sitemap v0.2.0 (breaking) |
| 33-04 | Sitemap extras: image sitemap, News sitemap (types/categories, 2-day window), IndexNow (admin key, key-file route, ping job from URL-set diffs), HTML-sitemap JSON groups API with paging | sm-sitemap-plugin | 33-03 | (same tag as 33-03 if released together) |
| 33-05 | SEO plugin I: `sitemap.Filter` impl (D-24/D-95), page registry (model, admin, signpost type, route-report endpoint, MCP), llms.txt, robots `Sitemap:` → index, batch conflict via filter, "sitemap URL blocked" issue, `/site` endpoint for the layer, site-cache purge after SEO writes | sm-seo-plugin | 33-03 | — |
| 33-06 | SEO plugin II: publisher settings (D-03/D-45), schema type + type fields + custom JSON-LD in SEO tab with validation (D-44/D-46), @graph builder per kind (D-43), breadcrumbs + primary category (D-86), alternates/x-default/og:locale (D-55..57), pagination fields + `?page=1`/form normalisation (D-40/41/42) in resolve | sm-seo-plugin | 33-01, 33-05 | seo v0.2.0 (after 33-05, 33-06, 33-10, 33-11) |
| 33-07 | `vue-seo-layer` runtime: create repo (tea), Nitro middleware (redirect/410/X-Robots-Tag/LRU), `useCmsSeo` head rendering + single JSON-LD script + `useSchemaNode`, root proxies (robots, sitemaps, llms, verification, IndexNow key), 404 report call, moved site-cache purge route clearing the LRU | vue-seo-layer (new) | 33-05/33-06 API shape (fixtures) | — |
| 33-08 | `vue-seo-layer` build + components: SSR build guard module, route report to registry, signed-in/noindex route meta (D-23), `<SeoBreadcrumbs>`, `usePagination` (validation, rel prev/next, suffixes, page-1 pillar), HTML sitemap page, route-drift startup check (D-20c) | vue-seo-layer | 33-07 | vue-seo-layer v0.1.0 |
| 33-09 | Layers onto the SEO layer: journal (drop `@nuxtjs/seo` and own meta, PostCard one link (D-80), H2 cards, htmlGate h1→h2, auto-links + outbound rules, image rules D-71, breadcrumbs, pagination), menu and FAQ (extend, FAQPage node, widget headings as styled text D-52, content in HTML), remove duplicated purge files | vue-journal-layer, vue-menu-layer, vue-faq-layer | 33-08 | journal-layer v0.7.0, menu v0.5.0, faq v0.5.0 |
| 33-10 | Crawler + issue report: job, storage, checks (status, canonical, robots, title/description, H1/levels/title consistency, JSON-LD parse + per-type validation, hreflang reciprocity, internal links/redirected links/generic anchors, missing alt/dimensions/lazy featured, raw-HTML content check, sitemap consistency), admin screen, MCP tools, merged issues | sm-seo-plugin | 33-05, 33-06 | — |
| 33-11 | Linking + media: auto-link phrase table (admin + MCP, keyphrase feed), related-posts option (Open Question 6), editor suggestions widget + orphan report from crawl link graph, outbound domain lists, missing-alt CMS report + bulk alt MCP tool, image format decision (WebP/AVIF/srcset or defer) | sm-seo-plugin (+ sm-journal-plugin if related posts change) | 33-10 | — |
| 33-12 | Apps I: sm-demo-app (bumps, declare `/p/**` and `/presentations/**` noindex, replace hand-written noindex with route meta, mount new sitemap, SEO layer), sm-summercmsio-app (Journal bump, layer bump; keeps `@nuxtjs/seo` for its static sitemap, Open Question 7), sm-bm-app D-72 (robots.txt `Disallow: /`, meta noindex, X-Robots-Tag) | sm-demo-app, sm-summercmsio-app, sm-bm-app | tags of 33-01..09 | — |
| 33-13 | grzybyfunkcjonalne.pl: framework v0.4.4→v0.5.2, Journal bump (`golem15.journal.tag_pages: false`), sitemap v0.2.0 migration, mount sm-seo-plugin, robots/sitemap from Nuxt to CMS, nginx locations for `/sitemap*.xml` and `/robots.txt`, layers v0.4.0→latest + SEO layer, smoke-ssr; deploy as a human checkpoint | sm-grzybyfunkcjonalne-app (+ its vue submodule) | 33-12 pattern | — |
| 33-14 | Unit tests (last plan) + `check-phase33.sh --all` + release tags in order + app pin bumps + changelog post | all touched repos | all | final tags |
**Wave sketch:**
```
W1: 33-01 (framework seam + v0.5.2)
W2: 33-02 (journal) ║ 33-03 (sitemap core)
W3: 33-04 (sitemap extras) ║ 33-05 (seo I) ║ 33-07 (layer runtime, against fixtures)
W4: 33-06 (seo II) ║ 33-08 (layer build/components)
W5: 33-09 (layers) ║ 33-10 (crawler)
W6: 33-11 (linking/media)
W7: 33-12 (apps I) → 33-13 (grzyby)
W8: 33-14 (unit tests + releases)
```
**Release order** (plugin first, then app pointer, per CLAUDE.md):
1. framework v0.5.2 + changelog post;
2. sm-journal-plugin;
3. sm-sitemap-plugin (breaking, minor bump);
4. sm-seo-plugin (requires sitemap: its go.mod gains `sm-sitemap-plugin`);
5. vue-seo-layer;
6. vue-journal-layer, vue-menu-layer, vue-faq-layer;
7. apps (go.mod + submodule pointer + package.json pins, separate commits);
8. deploys only on go-ahead.
## Don't Hand-Roll
| Problem | Don't build | Use instead | Why |
|---------|-------------|-------------|-----|
| XML escaping in sitemaps | another string replacer | `encoding/xml` (`xml.EscapeText`) | PHP byte parity is retired (D-32); stdlib handles every escape |
| HTML parsing in the crawler / Go transforms | regex over HTML | `golang.org/x/net/html` tokenizer/parser | Already in the graph; handles malformed HTML |
| robots.txt matching | own matcher | `grobotstxt` (D-99) | Google-faithful longest match |
| Outbound HTTP for crawler/IndexNow | raw `http.Client` | `fetchguard.Client` | SSRF guard, body cap, no redirect following |
| JSON-LD script escaping in Nuxt | manual `</script>` escaping | unhead `useHead({ script: [{ type: 'application/ld+json', innerHTML: graph }] })` | unhead escapes `<` as `<` for json types |
| Background work / schedules | goroutines + tickers | `conga` jobs + `pact.HasSchedule` | Leader election, unique periods, progress records |
| Cache purge of rendered pages | new purge endpoint | `cooler.PurgeSiteCacheAfterCommit` + existing `/api/__site__/cache/purge` | Already wired to every site |
| Redirect decision in the layer | re-implementing rules in TS | `GET /_seo/api/v1/resolve` | One source of truth (D-103) |
| Head tags | `@nuxtjs/seo` modules | Nuxt core `useHead`/`useSeoMeta` | Core composables; no competing graph |
**Key insight:** in this phase the CMS already holds every decision. Each frontend piece that "computes SEO" itself is a second source of truth that will drift: hreflang from i18n, canonical from seo-utils, schema from nuxt-schema-org, sitemap from @nuxtjs/sitemap. The layer should only render the answer and enforce it.
## Runtime State Inventory
D-32 is a migration of live sites, so this inventory applies.
| Category | Items found | Action required |
|----------|-------------|-----------------|
| Stored data | `golem15_sitemap_definitions` rows (grzyby, demo): `url` items and registry items with `priority`/`changefreq` (not read in the databases this session; the shape is known from code) [VERIFIED: models/definition.go, models/definition_item.go]. `golem15_sitemap_settings`: `frontend_url`, `frontend_default_locale`, `updates_manifest_path`, tier priorities [VERIFIED: models/settings.go] | **Data migration:** keep `url` items as the "pages" group; map `frontend_url` → site URL; drop the tier columns in a new migration (or leave them unused); first rebuild job fills the slot table |
| Live service config | Google Search Console sitemap submissions for grzybyfunkcjonalne.pl point at `/sitemap.xml` (assumed, not checked) | Keep the index at `/sitemap.xml`; optionally submit the per-type files for monitoring (D-30); a manual GSC task, not code |
| Live service config | grzyby nginx routes exactly `location = /sitemap.xml` and `location = /sitemap.xsl` to Go [VERIFIED: deploy/nginx/grzybyfunkcjonalne-staging.conf:145-161]; `/robots.txt` goes to Nuxt (@nuxtjs/robots), whose `robots` config sets `sitemap: '/sitemap.xml'` | Add a location for `^/sitemap(-[a-z0-9-]+)?\.xml$`, `/robots.txt`, `/llms.txt` and the IndexNow key file (or let the layer proxy them); remove the `sitemap.xsl` location; the production conf was not found in repo (only staging), so check with Jakub |
| OS-registered state | supervisor programs for grzyby (deploy/supervisor) | None expected (same binaries); verify after deploy |
| Secrets/env vars | `NUXT_CACHE_PURGE_TOKEN` / `site_cache.purge_token`; `golem15.seo.notfound_token` (new on grzyby); new IndexNow key (generated in admin, stored in DB, not an env var) | Add the notfound token and seo site URL to grzyby env; no rename of existing keys |
| Build artifacts | Nitro route cache on running Nuxt servers holds pages with the old head (two graphs from nuxt-schema-org) | Purge after deploy (the purge route) |
## Common Pitfalls
### Pitfall 1: Building on unreleased fixes
**What goes wrong:** Phase 33 plugins `require` v0.5.1 / seo v0.1.0 and silently miss CR-01..CR-04, or develop against `replace` and fail at tag time.
**How to avoid:** 33-01 releases the fixes together with the new seam; check that every plugin's `go.mod` requires the new tags in 33-14.
**Warning signs:** `go list -m` shows v0.5.1 in a plugin that needs a v0.5.2 field.
### Pitfall 2: Two JSON-LD graphs / two canonicals
**What goes wrong:** the journal layer keeps `@nuxtjs/seo`; nuxt-schema-org adds WebSite and WebPage; seo-utils adds a canonical. The CMS graph and canonical come in addition.
**How to avoid:** remove `@nuxtjs/seo` from the layers. A smoke expectation asserts exactly one `application/ld+json` script and one `rel="canonical"`.
### Pitfall 3: hreflang for missing translations
**What goes wrong:** using i18n's locale head, or the sitemap listing every enabled locale.
**How to avoid:** use only `ContentLocale` entries with a non-empty `URL`. Journal emits a single alternate with locale `"x-default"` when translate has no enabled locale [VERIFIED: sm-journal-plugin/classes/menu_item_types.go:53-55], so emit no hreflang when only one locale exists. hreflang must list itself and be reciprocal [CITED: developers.google.com/search/docs/specialty/international/localized-versions].
### Pitfall 4: lastmod changes on every save
**What goes wrong:** Journal's descriptor `LastMod` is `UpdatedAt`, which moves on any save. Google uses lastmod only "if it's consistently and verifiably accurate" [CITED: developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap].
**How to avoid:** use the event-driven lastmod store (Pattern 2); ignore empty `Changes`.
### Pitfall 5: Stable numbering broken by deletes
**What goes wrong:** re-chunking an ordered list after a delete shifts every later URL into an earlier file.
**How to avoid:** slot table with holes; only append.
### Pitfall 6: Shared purge file duplicated across layers
**What goes wrong:** journal, menu and FAQ ship `server/api/__site__/cache/purge.post.ts` and `server/lib/siteCachePurge.ts` "byte for byte" [VERIFIED: vue-journal-layer/server/api/__site__/cache/purge.post.ts header]. Nitro keeps one handler per route, so a new SEO-layer behaviour (clearing the resolve LRU) is lost when another layer's copy wins.
**How to avoid:** move the route and lib into `vue-seo-layer` and delete the copies in 33-09. The purge clears `nitro:routes` [VERIFIED: siteCachePurge.ts:16] and the resolve LRU.
### Pitfall 7: Generic "read more" and the image as a second link
**What goes wrong:** PostCard renders an image `NuxtLink` (`tabindex="-1" aria-hidden="true"`) and a `$t('journal.readMore')` link in addition to the title link [VERIFIED: vue-journal-layer/app/components/Journal/PostCard.vue:15-24]. That is three anchors to one URL, one of them generic.
**How to avoid:** title link only; make the card clickable with a CSS stretched link; drop the separate read-more anchor, or make its text "Czytaj: <title>" (D-81). Note the tension with D-80 in Open Question 8.
### Pitfall 8: XSL breaks in browsers
**What goes wrong:** keeping `<?xml-stylesheet?>` while Chrome 158 (2026-11-17) stops running XSLT on Stable [CITED: developer.chrome.com/docs/web-platform/deprecating-xslt].
**How to avoid:** remove the PI and `/sitemap.xsl`. The HTML sitemap (D-34) is the human view. Record in the plan that backlog todo w8kupqi3 is resolved by removal, and close it in g15office separately.
### Pitfall 9: News sitemap misuse
**What goes wrong:** listing all posts. Only articles "created in the last two days" belong; up to 1,000 `news:news` per sitemap; title without author or date [CITED: developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap].
**How to avoid:** the window is computed from the new `PublishedAt`; off by default.
### Pitfall 10: Build guard false confidence
**What goes wrong:** checking only `nuxt.options.routeRules`, while rules also come from `nitro.routeRules`, layers, `.client.vue` pages and experimental inline rules.
**How to avoid:** check all of them in `nitro:config` after layers merge. Test with a playground that has each kind.
### Pitfall 11: FAQ rich results are gone
**What goes wrong:** treating FAQPage validation as a Google rich-result requirement. Google removed the FAQ rich result: "This feature will no longer appear in Google Search starting May 7, 2026" [CITED: developers.google.com/search/docs/appearance/structured-data/faqpage].
**How to avoid:** still emit FAQPage, since it is valid schema.org and D-43 asks for it, but give it no rich-result validation rules. The Article type has "no required properties" in Google's docs; its recommended ones are author, dateModified, datePublished, headline and image [CITED: developers.google.com/search/docs/appearance/structured-data/article].
### Pitfall 12: Crawling staging
**What goes wrong:** every page reports noindex because of the D-22 staging lock.
**How to avoid:** suppress lock-caused robots issues when `Staging()` is true. Crawl over http on staging needs `fetchguard.TrustedMode`.
### Pitfall 13: grzyby jumps several versions at once
**What goes wrong:** framework v0.4.4→v0.5.2, Journal pseudo-version → v0.2.x, layers v0.4.0→v0.7.0 with breaking layer changes, plus SEO mount and sitemap migration, all on a live site.
**How to avoid:** a separate plan (33-13) with smoke-ssr expectations covering the public URLs that must stay identical (Phase 16 D-15), staging deploy first, production deploy as a human checkpoint.
## Code Examples
### Sitemap index and urlset with hreflang and images (Go, stdlib escaping)
```go
// Source: sitemaps.org protocol + Google image/hreflang docs (cited above). Names [ASSUMED].
const (
nsSitemap = "http://www.sitemaps.org/schemas/sitemap/0.9"
nsXHTML = "http://www.w3.org/1999/xhtml"
nsImage = "http://www.google.com/schemas/sitemap-image/1.1"
nsNews = "http://www.google.com/schemas/sitemap-news/0.9"
)
func writeIndex(w io.Writer, files []IndexFile) error {
bw := bufio.NewWriter(w)
bw.WriteString(xml.Header + `<sitemapindex xmlns="` + nsSitemap + `">`)
for _, f := range files {
bw.WriteString("<sitemap><loc>")
xml.EscapeText(bw, []byte(f.Loc))
bw.WriteString("</loc>")
if !f.LastMod.IsZero() {
bw.WriteString("<lastmod>" + f.LastMod.UTC().Format(time.RFC3339) + "</lastmod>")
}
bw.WriteString("</sitemap>")
}
bw.WriteString("</sitemapindex>\n")
return bw.Flush()
}
// Each <url>: <loc>, optional <lastmod>, then when >1 locale every <xhtml:link rel="alternate" hreflang=".." href=".."/>
// including itself and x-default, then up to 1000 <image:image><image:loc>..</image:loc></image:image>.
// No <priority>, no <changefreq> (D-30; Google ignores them).
```
### IndexNow ping (Go, fetchguard)
```go
// Source: indexnow.org/documentation (cited). Key: 8–128 chars [a-zA-Z0-9-], served at /<key>.txt.
body := map[string]any{"host": host, "key": key, "keyLocation": site + "/" + key + ".txt", "urlList": urls} // ≤10,000 URLs
res, err := client.PostJSON(ctx, "https://api.indexnow.org/indexnow", body) // fetchguard.Client, AllowHosts api.indexnow.org
// 200/202 ok; 403 key file missing; 422 host mismatch; 429 back off (retry via conga attempts)
```
### Layer head from the resolve answer (Nuxt core only)
```ts
// Source: Nuxt core useHead/useSeoMeta; field names of `seo` VERIFIED (resolver.go:72-101), new fields [ASSUMED].
export function useCmsSeo() {
const res = useRequestEvent()?.context.seo ?? useState('cms-seo').value
if (!res?.seo) return
const s = res.seo
useSeoMeta({ title: s.title, description: s.description, robots: s.robots,
ogTitle: s.og.title, ogDescription: s.og.description, ogImage: s.og.image || undefined, ogUrl: s.og.url,
twitterCard: s.twitter.card, twitterSite: s.twitter.site || undefined })
useHead({
htmlAttrs: { lang: res.html_lang },
link: [{ rel: 'canonical', href: s.canonical, key: 'canonical' },
...(res.alternates ?? []).map((a) => ({ rel: 'alternate', hreflang: a.hreflang, href: a.url, key: `alt-${a.hreflang}` }))],
meta: s.verification.map((v) => ({ name: v.name, content: v.content })),
script: [{ type: 'application/ld+json', key: 'jsonld', innerHTML: mergeGraph(res.jsonld, useSchemaNodes()) }], // unhead escapes '<'
})
}
```
### Phrase auto-linking over sanitize-html output (TS)
```ts
// sanitize-html serializes normalized HTML with escaped text, so a tag/text split is reliable on ITS output.
const SKIP = new Set(['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'figcaption', 'button'])
export function autolink(html: string, links: { phrase: string; url: string }[], max: number, self: string): string {
const stack: string[] = []; let used = 0; const done = new Set<string>()
return html.split(/(<[^>]+>)/).map((part) => {
if (part.startsWith('<')) { trackTag(stack, part); return part }
if (used >= max || stack.some((t) => SKIP.has(t))) return part
for (const l of links) {
if (done.has(l.url) || l.url === self || used >= max) continue
const i = indexOfWord(part, escapeHtml(l.phrase)) // case-insensitive, word boundary, first occurrence
if (i < 0) continue
done.add(l.url); used++
part = part.slice(0, i) + `<a href="${escapeAttr(l.url)}">` + part.slice(i, i + l.phrase.length) + '</a>' + part.slice(i + l.phrase.length)
}
return part
}).join('')
}
```
### Journal H1 → H2 in the write gate (Go)
```go
// After SanitizeHTMLReport: rewrite <h1 ...> / </h1> start and end tags to h2 using the x/net/html tokenizer,
// count conversions, and surface "n headings converted" as a save notice (admin) / response field (API, MCP).
// Editor: StarterKit.configure({ heading: { levels: [2, 3, 4] } }) plus a parse rule mapping <h1> to level 2.
// Layer defence in depth: sanitize-html transformTags: { h1: 'h2' } in htmlGate.
```
## State of the Art
| Old approach | Current approach | When changed | Impact |
|--------------|------------------|--------------|--------|
| `<priority>`, `<changefreq>` | Omitted; Google ignores them | long-standing [CITED: build-sitemap docs] | D-30 drops them |
| image:caption/title/geo_location/license | Only `image:image` + `image:loc` | Google removed the others [CITED: image-sitemaps docs] | Smaller image entries |
| `rel=prev/next` for Google | "Google no longer uses these tags"; other engines may [CITED: pagination docs] | — | D-42 keeps them for Bing; numbered `<a href>` links are what matters |
| Browser XSLT view of sitemaps | Removed from Chrome Stable in Chrome 158, 2026-11-17 [CITED: Chrome deprecating-xslt] | 2026 | Drop the XSL output |
| FAQ rich results | Not shown in Google Search from 2026-05-07 [CITED: faqpage docs] | 2026 | Keep FAQPage, no rich-result validation |
| `@nuxtjs/seo` all-in-one | CMS-driven head via core composables for headless CMS sites | — | Layer replaces the module |
**Deprecated/outdated in the repos:**
- sm-sitemap-plugin priority tiers config (`golem15.sitemap.priority_tiers`), the frontend_url rewrite and the PHP goldens (`classes/testdata/*.golden`). D-32 retires them.
- `classes.BeforeAddItem`/`AddItem` festival events: no consumer outside the plugin [VERIFIED: grep across siblings]. Replace them with the `Filter` interface.
## Assumptions Log
| # | Claim | Section | Risk if wrong |
|---|-------|---------|---------------|
| A1 | New pact fields named `PublishedAt`, `Parents`, `Authors`, `Images`, `Videos`, `Kind` are acceptable as generic, additive seam (D-90/D-106) | Pattern 1 | Seam shape rework; needs Jakub's blocking checkpoint like 32-01/T1 |
| A2 | `sitemap.Filter` interface with batched `Decide` and a `Group` override | Pattern 3 | Page-registry kinds cannot split into per-kind files otherwise |
| A3 | Resolve answer field names (`alternates`, `jsonld`, `breadcrumbs`, `pagination`, `autolinks`, `outbound`, `html_lang`, `og_locale`) | Pattern 4 | Layer/plugin contract; consumed by every app (D-33 "costly") |
| A4 | Keep the index at `/sitemap.xml` (per-type files `sitemap-<slug>-<n>.xml`) | Plan 33-03 | GSC submission changes if a different index URL is chosen |
| A5 | Remove XSL output now | Pitfall 8 | Editors lose the styled XML view (HTML sitemap replaces it) |
| A6 | Layer drops `@nuxtjs/seo` entirely (journal layer), summercms.io keeps its own | Standard Stack | `useSiteConfig` callers and `linkChecker`/`robots`/`sitemap` options in apps must be removed |
| A7 | Middleware enforces only redirect and gone; not_found left to Nuxt routing | Pattern 5 | Soft-404 risk for unknown paths that a catch-all page renders with 200 |
| A8 | Crawler default limits (5000 pages, concurrency 2, 250 ms, 10 s, 5 MiB) | Pattern 7 | Load on small servers; configurable |
| A9 | Event-driven lastmod with an attribute ignore list | Pattern 2 | Lastmod too eager or too lazy |
| A10 | Auto-links applied at render in the layer, not stored in content | Code Examples | Links absent from the API/RSS output; crawler still sees them |
| A11 | grzyby production nginx mirrors the staging conf (only staging conf found in repo) | Runtime State | Missing sitemap routes after deploy |
| A12 | GSC for grzyby has `/sitemap.xml` submitted | Runtime State | None if index stays at the same URL |
| A13 | Optional Go modules gen2brain/webp, gen2brain/avif, chromedp are legitimate and maintained | Package audit | Supply-chain risk; gated behind checkpoint + decision note |
## Open Questions (RESOLVED)
All resolved on 2026-10-28; the decisions are recorded in 33-CONTEXT.md, Planning section.
1. **Release cadence for a 10-repo phase. RESOLVED: all tags in 33-14 (CONTEXT, release cadence)**
- Known: apps pin plugins by Go tag and layers by pnpm git tag; Phase 32 tagged only in its last plan and staged the demo mount as a patch.
- Unclear: whether Jakub allows intermediate pushes and tags. Plugin tags after 33-03..06, layer tags after 33-08/09.
- Recommendation: allow intermediate tags, each behind a `checkpoint:human-verify` "push and tag" step. Otherwise 33-12 and 33-13 can only produce patches applied in 33-14, as 32-05 did.
2. **D-70 "rendered" HTML. RESOLVED: optional chromedp when a Chrome path is configured (CONTEXT, rendered comparison)**
- Known: a no-JS comparison needs no browser; a rendered DOM needs headless Chromium (chromedp + Chrome on the server). Chromium 154 exists on this dev machine; production servers unknown.
- Recommendation (MVP): the raw HTML is checked against CMS expectations (descriptor title in H1, word count vs `Words`, CMS-known links present). Rendered comparison deferred, or optional when a Chrome path is configured.
3. **D-85 WebP/AVIF/srcset. RESOLVED: WebP now in Go via gen2brain/webp, AVIF deferred (CONTEXT, image formats)**
- Known: `lagoon/attach` decodes webp but cannot encode it ("a webp thumbnail holds JPEG bytes") [VERIFIED: modules/lagoon/attach/thumb.go:22-24]. Encoding needs a new dependency.
- Options: (a) gen2brain/webp+avif in a plugin (WASM, no cgo); (b) Nuxt image pipeline (new npm deps, sharp native); (c) defer formats, ship srcset widths from existing JPEG/PNG thumbs plus alt/filename rules.
- Recommendation: (c) for MVP, with (a) as a follow-up decision note.
4. **Success criterion 3 vs layout issues. RESOLVED: data issues name an MCP tool, code issues carry fix: code (CONTEXT)** Some crawler findings (two H1 from a layout, a missing breadcrumb component) are frontend code, not data. Recommendation: issue kinds carry either a `tool` or `fix: "code"`. The criterion then reads as: every data issue has a tool. Confirm with Jakub and Dawid.
5. **Site URL in the sitemap plugin. RESOLVED: setting, then config, then app.url (CONTEXT, defaults)** Sitemap cannot read `golem15.seo.site_url` (D-95). Recommendation: a sitemap setting or config `golem15.sitemap.site_url` defaulting to `app.url`; the SEO plugin reports a mismatch as an issue.
6. **Related posts (D-82) RESOLVED: opt-in, current behaviour default (CONTEXT, defaults)** changes Journal API semantics (today: same category, limit 3, `published_at DESC`) [VERIFIED: sm-journal-plugin/controllers/api/posts_helpers.go:152-172]. Recommendation: opt-in config in Journal (count 3–6, tag+category scoring), default = current behaviour (porting contract).
7. **summercms.io scope. RESOLVED: pin bumps and build guard only (CONTEXT, defaults)** It is a `nuxt generate` site with no sitemap or SEO plugin mounted, and it uses `@nuxtjs/seo` for its own sitemap. Recommendation: Phase 33 only bumps its Journal and layer pins and checks the build guard; a full move is deferred.
8. **D-80 vs D-81 "read more". RESOLVED: no read-more anchor (UI-SPEC L5, Q-11)** D-80 says only the title is a text link; D-81 allows "read more" carrying the title. Recommendation: no separate read-more anchor in the layer component.
9. **D-20c "layer routes reading their patterns from the CMS". RESOLVED: build-time options plus startup drift check (UI-SPEC L3)** Nuxt routes are build-time. Recommendation: build-time options stay, plus a startup drift check against CMS patterns (logged, and reported by the crawler), rather than a build-time network fetch.
10. **"The user plugin's auth layer" (D-23) RESOLVED: route meta, no user layer (CONTEXT, defaults)** does not exist as a Nuxt layer today; there are only vue-journal, menu, FAQ and paymentgateway layers. Recommendation: the SEO layer honours `definePageMeta({ requiresAuth: true })` / `seo: { robots }`; apps mark their auth pages; a user layer is out of scope.
## Environment Availability
| Dependency | Required by | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go repos | ✓ | go1.27.2 | — |
| Node | layers, apps | ✓ | v24.22.0 (engines >=22.18) | — |
| pnpm | layer installs | ✓ | 12.14.0 | npm 12.2.1 |
| Docker | testcontainers Postgres suites | ✓ | 29.9.1 (server reachable as golem) | `-short` only |
| tea | create `vue-seo-layer` repo | ✓ | /usr/bin/tea | — |
| Chromium | only if rendered crawl chosen | ✓ (dev) | 154.0.8037.97 | raw-HTML checks |
| Write access to summercms.go `.git` and phase dir | docs commits | ✓ | owned by golem now (32-03 had a jin:jin blocker) | — |
**Missing dependencies with no fallback:** none for the MVP scope.
**Missing with fallback:** headless browser on production (raw-HTML check instead).
## Validation Architecture
### Test framework
| Property | Value |
|----------|-------|
| Framework (Go) | `go test` + testcontainers Postgres (`TestMain` skips Docker under `testing.Short()`) |
| Framework (layers) | `node --test --test-reporter=tap tests/*.test.ts` (pure TS utils) + `testing/smoke-ssr.mjs` against a built playground with a fixture API |
| Framework (admin elements) | vitest 3.2.7 + happy-dom 20.11.6 in sm-seo-plugin / sm-journal-plugin |
| Config file | per repo (`package.json` scripts, `vite.config.ts`); new `scripts/check-phase33.sh` in summercms.go |
| Quick run command | `go test -short ./...` in the touched repo; `npm test` / `pnpm test` in the touched layer |
| Full suite command | `bash scripts/check-phase33.sh --all` (modelled on check-phase32.sh: self-test, go, plugins, layers, consumers, docker, docs, marker, additive, compat) |
### Phase requirements → test map
| Req ID | Behavior | Test type | Automated command | File exists? |
|--------|----------|-----------|-------------------|-------------|
| D-30 | index + per-type files of N URLs, stable numbering after a delete, lastmod only on real change | integration (Postgres) | `go test ./... -run 'TestSitemapIndex\|TestStableNumbering\|TestLastmod'` (sm-sitemap-plugin) | ❌ Wave 0 |
| D-31 | image entries ≤1000/url; news window 2 days; IndexNow ping on URL diff (fake transport via `fetchguard.WithTransport`) | unit + integration | `go test ./... -run 'TestImageSitemap\|TestNewsSitemap\|TestIndexNow'` | ❌ |
| D-32 | no priority/changefreq/XSL; url items migrated | integration | `go test ./... -run TestMigrateDefinitions` | ❌ |
| D-95/D-24 | noindex/canonical/redirected excluded via SEO filter; sitemap works without SEO | integration (two apps) | `go test ./... -run 'TestFilterExcludes\|TestNoFilter'` | ❌ |
| D-33 | registry page resolves ok; route report upserts; page kinds group into files | integration | `go test ./... -run 'TestRegistry'` (sm-seo-plugin) | ❌ |
| D-43..46 | @graph per kind with @id links; custom JSON-LD refused when invalid; validator warnings | unit | `go test ./classes/schema/...` | ❌ |
| D-55..57 | alternates only for translated locales; x-default = default locale | integration | `go test ./... -run TestAlternates` | ❌ |
| D-40..42 | `?page=1` → 301; other form → 301 to configured; suffix templates | integration | `go test ./... -run TestPagination` | ❌ |
| D-50/51 | H1→H2 on admin/API/MCP/import, notice; command dry run | integration (Journal) | `go test ./... -run 'TestHeadingConversion'` | ❌ |
| D-70 | build fails for indexable `ssr:false`, `.client.vue`; passes when declared noindex | build smoke | `pnpm build:playground` with a fixture playground per case (expect exit 1/0) | ❌ |
| D-20a/D-08/D-103 | middleware: 301/410, X-Robots-Tag, verification file at root, LRU purged | SSR smoke | `node testing/smoke-ssr.mjs --expect testing/expect-seo.txt` | ❌ |
| D-43 (layer) | exactly one ld+json script, one canonical | SSR smoke | same expect file (`contains=`/`notcontains=`) | ❌ |
| D-80/81/52 | one anchor per card, H2 cards, no generic anchor | SSR smoke + unit | journal layer `pnpm test` + smoke | ❌ |
| D-14 et al. | crawler checks against an httptest site with planted issues; each issue has tool or `fix:code` | integration | `go test ./... -run TestCrawler` | ❌ |
| D-10 | every new admin operation has an MCP tool | unit | `go test -short ./... -run TestEveryAdminOperationHasTool` | ✅ (extend) |
| D-82/83 | autolink first occurrence, max N, not in headings/links/self; outbound rel | unit (TS + Go) | `pnpm test` (vue-seo-layer / journal layer) | ❌ |
| D-72 | bm serves `Disallow: /` and noindex | smoke | `curl -sI` in check script against a local build | ❌ |
| D-90/D-106 | framework additions name no SEO/app; additive only | gate | `check-phase33.sh --marker --additive` | ❌ (copy from phase32) |
### Sampling rate
- **Per task commit:** `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer.
- **Per wave merge:** Docker suites of the touched repos + layer playground build + smoke-ssr.
- **Phase gate:** `scripts/check-phase33.sh --all` green before `/msd-verify-work`.
### Wave 0 gaps
- [ ] `scripts/check-phase33.sh` (copy the stage engine of check-phase32.sh; add a `layers` stage: node tests, playground builds, smoke-ssr).
- [ ] sm-sitemap-plugin: retire `classes/golden_test.go` + `testdata/*.golden`; new test fixtures for multi-type, multi-locale content (fake content type plugin, as sm-seo-plugin's `fixture_test.go` does).
- [ ] vue-seo-layer: `.playground`, `testing/` (copy smoke-ssr, fixture-api, nuxt-server from vue-journal-layer), fixture `/_seo/api/v1/resolve` answers.
- [ ] Build-guard fixture playgrounds (indexable ssr:false → fail; declared noindex → pass; `.client.vue` page → fail).
- [ ] Crawler httptest site with planted issues.
## Security Domain
### Applicable ASVS categories
| ASVS category | Applies | Standard control |
|---------------|---------|-----------------|
| V2 Authentication | yes (route report, 404 report, purge) | bearer tokens ≥32 chars (existing purge rule), backend guard + `golem15.seo.manage` for management routes |
| V3 Session Management | no | — |
| V4 Access Control | yes | one SEO permission (D-15); MCP tools listed only with the permission; read grants see GET tools only |
| V5 Input Validation | yes | custom JSON-LD: parse, size/depth caps, objects only, merge by `@id`; autolink URLs internal paths or http(s); IndexNow key `[A-Za-z0-9-]{8,128}`; verification names `[A-Za-z0-9._-]+\.(html\|txt\|xml)` [VERIFIED: sm-seo-plugin/classes/verification.go:11] |
| V6 Cryptography | yes (IndexNow key) | `crypto/rand` hex key; never hand-roll |
| V12/V13 SSRF / outbound | yes | `fetchguard` for crawler and IndexNow; crawler follows only same-host links |
### Known threat patterns
| Pattern | STRIDE | Standard mitigation |
|---------|--------|---------------------|
| SSRF through crawled links or a site URL pointing inside the network | Elevation | fetchguard host allow-list = site host; TrustedMode only from config, never from page content |
| Script breakout from JSON-LD (editor custom JSON with `</script>`) | Tampering/XSS | unhead escapes `<` in json scripts; also validate on save |
| Open redirect via middleware | Spoofing | Location always from the resolver, absolute on the site URL (Phase 32 guarantee); layer never builds Location from the request |
| XML injection in sitemaps | Tampering | `xml.EscapeText` on every value; only http(s) locs |
| Crawl as DoS on own site | DoS | concurrency/delay caps, one crawl at a time (conga unique), max pages |
| Resolve endpoint flooding through the Nuxt server | DoS | LRU cache in the layer; forward X-Forwarded-For so the per-IP bucket applies per visitor |
| Route report poisoning the registry | Tampering | bearer token; report only inserts unknown static paths, never deletes editor data |
| Auto-link injection | XSS | phrase/URL validated on save; layer escapes attribute values; links only into text nodes |
## Sources
### Primary (HIGH confidence, code read this session)
- `summercms.go/modules/pact/content.go`, `modules/pact/capabilities.go:956-1010`, `modules/signpost/content.go`, `modules/signpost/signpost.go`, `modules/backpack/services.go`, `modules/cooler/sitecache.go`, `modules/fetchguard/README.md`, `modules/conga/README.md`, `modules/lagoon/attach/thumb.go`
- `sm-sitemap-plugin/` (plugin.go, routes.go, classes/generator.go, serialize.go, events.go, models/*, config/config.yaml, README)
- `sm-seo-plugin/` (routes.go, mcp.go, operations.go, classes/resolver.go, issues.go, verification.go, robots.go, config/config.yaml)
- `sm-journal-plugin/` (classes/content_descriptors.go, menu_item_types.go, format_html.go, assets/src/blogeditor/*, controllers/api/posts_helpers.go)
- `vue-journal-layer/` (nuxt.config.ts, PostCard.vue, PostPage.vue, Pagination.vue, seo.ts, sitemap source, purge route + lib, htmlGate.ts, testing/smoke-ssr.mjs); node_modules h3 1.15.11, unhead 2.1.17, sanitize-html 2.18.0
- Apps: grzyby `nuxt.config.ts`, `package.json`, nginx staging conf, `.gitmodules`, `summer.yaml`; demo `nuxt.config.ts`; summercms.io `nuxt.config.ts`; bm `frontend/`
- Planning: 33-CONTEXT, 32-CONTEXT, 32-DECISIONS-MAP, 32-02..06 SUMMARYs, 32-UAT, 32-VERIFICATION, deferred-items, 32-06-release-log, STATE.md, ROADMAP.md
### Secondary (cited official docs; seam tier LOW for WebFetch)
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap
- https://developers.google.com/search/docs/specialty/international/localized-versions
- https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading
- https://developers.google.com/search/docs/appearance/structured-data/article
- https://developers.google.com/search/docs/appearance/structured-data/breadcrumb
- https://developers.google.com/search/docs/appearance/structured-data/faqpage
- https://www.sitemaps.org/protocol.html
- https://www.indexnow.org/documentation
- https://developer.chrome.com/docs/web-platform/deprecating-xslt
- https://nuxt.com/docs/4.x/guide/concepts/rendering, /directory-structure/app/pages, /api/utils/define-route-rules, /api/advanced/hooks, /directory-structure/server
- https://nuxtseo.com/docs/schema-org/guides/default-schema-org, https://nuxtseo.com/docs/nuxt-seo/getting-started/introduction
### Tertiary (LOW, needs validation)
- Optional Go modules (gen2brain/webp, gen2brain/avif, chromedp): only registry existence checked on proxy.golang.org.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH for what exists (read in repos); MEDIUM for the removal of `@nuxtjs/seo` (design judgement backed by cited module behaviour).
- Architecture: MEDIUM. The seams are verified; the new interfaces and answer fields are recommendations needing a shape checkpoint.
- Pitfalls: HIGH for repo-derived ones (PostCard, purge duplication, lastmod, staging); MEDIUM for external spec ones (cited).
- Plan breakdown: MEDIUM. It depends on Open Question 1 (release cadence) and the UI-SPEC.
**Research date:** 2026-10-28
**Valid until:** 2026-11-17 for the XSL item (Chrome 158); about 30 days for the rest (stable specs, pinned toolchain).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
---
phase: "33"
slug: "seo-output-audit-and-content"
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
status: draft
nyquist_compliant: false
wave_0_complete: false
created: "2026-10-28"
---
# Phase 33 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution. Source: `33-RESEARCH.md` § Validation Architecture.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | `go test` + testcontainers Postgres (Go repos); `node --test` + `testing/smoke-ssr.mjs` (Nuxt layers); vitest + happy-dom (admin elements) |
| **Config file** | per repo (`package.json` scripts, `vite.config.ts`); new `scripts/check-phase33.sh` in summercms.go (Wave 0) |
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
| **Full suite command** | `bash scripts/check-phase33.sh --all` |
| **Estimated runtime** | ~300 seconds (full, with Docker and playground builds) |
---
## Sampling Rate
- **After every task commit:** Run the quick command in the touched repo
- **After every plan wave:** Docker suites of the touched repos + layer playground build + smoke-ssr
- **Before `/msd-verify-work`:** `scripts/check-phase33.sh --all` must be green
- **Max feedback latency:** 120 seconds (quick loop)
---
## Per-Task Verification Map
Filled by the planner per task. Decision → test map from research:
| Decision | Behavior | Test Type | Automated Command | File Exists | Status |
|----------|----------|-----------|-------------------|-------------|--------|
| D-30 | index + per-type files, stable numbering after delete, lastmod only on real change | integration | `go test ./... -run 'TestSitemapIndex\|TestStableNumbering\|TestLastmod'` (sm-sitemap-plugin) | ❌ W0 | ⬜ pending |
| D-31 | image ≤1000/url, news 2-day window, IndexNow ping on diff | unit + integration | `go test ./... -run 'TestImageSitemap\|TestNewsSitemap\|TestIndexNow'` | ❌ W0 | ⬜ pending |
| D-32 | no priority/changefreq/XSL; url items migrated | integration | `go test ./... -run TestMigrateDefinitions` | ❌ W0 | ⬜ pending |
| D-95/D-24 | SEO filter excludes noindex/canonicalised/redirected; sitemap works without SEO | integration | `go test ./... -run 'TestFilterExcludes\|TestNoFilter'` | ❌ W0 | ⬜ pending |
| D-33 | registry pages resolve; route report upserts | integration | `go test ./... -run TestRegistry` (sm-seo-plugin) | ❌ W0 | ⬜ pending |
| D-43..46 | @graph per kind with @id links; invalid custom JSON-LD refused | unit | `go test ./classes/schema/...` | ❌ W0 | ⬜ pending |
| D-55..57 | alternates only for translated locales; x-default = default | integration | `go test ./... -run TestAlternates` | ❌ W0 | ⬜ pending |
| D-40..42 | `?page=1` → 301; form normalisation; suffix templates | integration | `go test ./... -run TestPagination` | ❌ W0 | ⬜ pending |
| D-50/51 | H1→H2 on every write path; command dry run | integration | `go test ./... -run TestHeadingConversion` (Journal) | ❌ W0 | ⬜ pending |
| D-70 | build fails for indexable `ssr:false` / `.client.vue`; passes when noindex | build smoke | fixture playground builds (expect exit 1/0) | ❌ W0 | ⬜ pending |
| D-20a/D-08/D-103 | middleware 301/410, X-Robots-Tag, verification file at root, cache purge | SSR smoke | `node testing/smoke-ssr.mjs --expect testing/expect-seo.txt` | ❌ W0 | ⬜ pending |
| D-43 (layer) | exactly one ld+json script, one canonical | SSR smoke | same expect file | ❌ W0 | ⬜ pending |
| D-80/81/52 | one anchor per card, H2 cards, no generic anchor | SSR smoke + unit | journal layer `pnpm test` + smoke | ❌ W0 | ⬜ pending |
| D-14 | crawler finds planted issues; each issue has a tool or `fix: code` | integration | `go test ./... -run TestCrawler` | ❌ W0 | ⬜ pending |
| D-10 | every new admin operation has an MCP tool | unit | `go test -short ./... -run TestEveryAdminOperationHasTool` | ✅ (extend) | ⬜ pending |
| D-82/83 | autolink rules; outbound rel | unit | `pnpm test` (vue-seo-layer / journal layer) | ❌ W0 | ⬜ pending |
| D-72 | bm serves `Disallow: /` and noindex | smoke | `curl -sI` in check script | ❌ W0 | ⬜ pending |
| D-90/D-106 | framework additions generic and additive | gate | `scripts/check-phase33.sh --marker --additive` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `scripts/check-phase33.sh` — stage engine from check-phase32.sh plus a `layers` stage
- [ ] sm-sitemap-plugin: retire `classes/golden_test.go` + `testdata/*.golden`; multi-type, multi-locale fixtures
- [ ] vue-seo-layer: `.playground`, `testing/` (smoke-ssr, fixture-api, nuxt-server), fixture resolve answers
- [ ] Build-guard fixture playgrounds (indexable ssr:false → fail; noindex → pass; `.client.vue` → fail)
- [ ] Crawler httptest site with planted issues
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| grzybyfunkcjonalne.pl serves the new sitemap index in production | Success criterion 1 | Production deploy needs go-ahead | After deploy: fetch `/sitemap.xml`, follow each child, submit index in GSC |
| Rich Results test passes for a post, a category and an FAQ page | D-46 | External Google tool | Run the admin "Rich Results" link per page kind |
| Crawl → MCP dry-run fix workflow | Success criterion 3 | Agent-driven workflow | Dawid runs a crawl, asks his Claude client to fix issues with `dry_run`, then applies |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 120s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View File

@@ -0,0 +1,19 @@
# API Coverage — IndexNow
> Full coverage by default. Opt-outs are explicit, reasoned decisions. Phase 33 integrates one external API: IndexNow (D-31), in `sm-sitemap-plugin` plan 33-04. Google Search Console, the Rich Results Test and the News sitemap are not API integrations (the admin links to the Rich Results Test page; sitemaps are files Google fetches).
| capability | decision | reason |
|---|---|---|
| bulk URL submission (POST JSON {host, key, urlList} to api.indexnow.org) | INTEGRATE | |
| key generation (32 hex characters from crypto/rand) and key rotation | INTEGRATE | |
| key file served at the site root (/<key>.txt) | INTEGRATE | |
| success answers 200 and 202 recorded as the last notification | INTEGRATE | |
| 400 bad request recorded in the last status | INTEGRATE | |
| 403 key not valid shown in the admin (S2 err_key) | INTEGRATE | |
| 422 URLs not matching the host shown in the admin (S2 err_host) | INTEGRATE | |
| 429 too many requests: retry with the job's attempts, shown in S2 (err_rate) | INTEGRATE | |
| batching at most 10 000 URLs per request | INTEGRATE | |
| notification of new, changed and removed URLs (rebuild diff) | INTEGRATE | |
| keyLocation parameter (key file elsewhere on the host) | OPT-OUT | the key file is served at the root, which covers every URL of the host; a key file elsewhere would narrow the URLs the key may submit |
| single-URL GET submission (?url=&key=) | OPT-OUT | the bulk POST covers single URLs (a urlList of one) |
| per-engine endpoints (bing.com/indexnow, yandex.com/indexnow and others) | OPT-OUT | api.indexnow.org shares every submission with all participating engines |

View File

@@ -0,0 +1,173 @@
---
phase: quick-261028-fsq
plan: 01
quick_id: 261028-fsq
slug: sm-user-plugin-mcpdispatch-credential-ca
type: execute
mode: quick
wave: 1
depends_on: []
autonomous: true
requirements:
- fsq-1 # MCPUserGuard.MCPDispatch: for a personal-token subject (ClientID empty, as MCPResolvePersonalToken builds it) the in-memory *models.APIToken credential carries ID = subject.TokenID, so a handler under MCP dispatch can record which personal token made a change
- fsq-2 # an OAuth subject (ClientID set) keeps credential ID 0 even when its TokenID is non-zero (that id is the OAuth server's token row, not a user_api_tokens row); the credential is still never saved
- fsq-3 # MCPDispatch doc comment and the README "MCP guard" paragraph describe the new credential id; one fix commit in sm-user-plugin, no trailer, not pushed, not tagged, the fonoteka.go submodule pointer untouched
files_modified:
# sm-user-plugin (module git.golem15.com/golem15/sm-user-plugin), a separate git repo checked out as the
# fonoteka.go submodule plugins/golem15/user (clean at v0.1.13 = 07196a4 on master). Edit and commit THERE only.
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
- ../fonoteka.go/plugins/golem15/user/README.md
estimate:
tokens: 45000
raw_tokens: 45000
tasks: 2
confidence: low # estimate-calibration: factor 1, sample_count 0
must_haves:
truths:
- "A subject resolved from a personal usr_ token by MCPResolvePersonalToken (ClientID empty, TokenID = the token row id) is dispatched by MCPUserGuard.MCPDispatch with a *models.APIToken credential whose ID equals that token's id, whose UserID is the user's and whose scopes are the session's."
- "That credential, carried by the bouncer dispatch marker, still passes the plugin's own route chain: GET /_user/api/v1/token/me answers 200 with the user's email, and the user's user_api_tokens row count is unchanged afterwards (the credential is not saved)."
- "A subject with ClientID set and a non-zero TokenID (even one equal to an existing user_api_tokens id) is dispatched with credential ID 0, UserID the user's and the session's scopes."
- "A subject with neither ClientID nor TokenID (the existing tests' shape) still gets credential ID 0; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged."
- "MCPDispatch's doc comment and the README's MCP guard paragraph say the credential carries a personal token's id and keeps ID 0 for an OAuth subject, and that it is never saved."
- "One new commit on sm-user-plugin master (descendant of 07196a4), subject starting with 'fix:', touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, no Co-Authored-By trailer, go.mod/go.sum unchanged, green on gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test; not pushed, not tagged; the fonoteka.go repo has nothing staged or committed for plugins/golem15/user."
artifacts:
- path: ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
provides: "MCPDispatch sets credential.ID = subject.TokenID when subject.ClientID is empty; updated doc comment"
contains: "subject.ClientID == \"\""
- path: ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
provides: "TestUserMCPGuard subtests 'dispatch credential carries the personal token id' and 'dispatch credential of an OAuth subject has no id' (Docker-backed, skip under -short)"
contains: "dispatch credential carries the personal token id"
- path: ../fonoteka.go/plugins/golem15/user/README.md
provides: "MCP guard paragraph describes the credential id"
contains: "the credential's ID is that token's id"
key_links:
- from: "classes/mcp_guard.go MCPResolvePersonalToken"
to: "classes/mcp_guard.go MCPDispatch"
via: "pact.MCPSubject{ClientID: \"\", TokenID: token.ID} built by the resolver and handed back by the framework MCP endpoint"
pattern: "TokenID: token.ID"
- from: "MCPDispatch credential"
to: "bouncer.Credential(r.Context()) in route handlers / middleware.Scope"
via: "pact.MCPDispatch.Credential -> bouncer.Dispatch.Credential"
pattern: "Credential: credential"
---
<objective>
Make the in-memory token credential that `MCPUserGuard.MCPDispatch` (sm-user-plugin) builds for an MCP tool call carry the personal API token's id when the MCP session was opened with a personal `usr_` token, so a consuming application's handler that reads `bouncer.Credential` under MCP dispatch can record which personal token made a change. For an OAuth subject the credential keeps ID 0, because that subject's `TokenID` is the OAuth server plugin's own token row id, not a `user_api_tokens` row.
Purpose: today the credential is always built with ID 0, so attribution of changes to a personal token is impossible under MCP dispatch (the HTTP `user.api_token` guard path already hands handlers the real row).
Output: one `fix:` commit in the sm-user-plugin repo (code, tests, README), local only.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_test.go
@/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/mcp_guard_smoke_test.go
Established facts (do not re-investigate):
- Work directory for all code edits, tests and the commit: `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user` (its own git repo, `origin` = git.golem15.com/golem15/sm-user-plugin, branch master, clean at tag v0.1.13 = `07196a4`). It is a submodule of fonoteka.go; committing in it makes fonoteka.go show the pointer as modified. Do NOT stage or commit that pointer in fonoteka.go. Nothing in summercms.go is edited by this plan (the orchestrator commits the planning docs).
- Run Go commands with `GOWORK=off` from the plugin directory (fonoteka.go/go.work lists this plugin; GOWORK=off tests it against its own go.mod requirement, summercms v0.5.0, as the previous user-plugin quick tasks did). Docker is reachable directly (no sudo needed).
- Interfaces in play (framework, `git.golem15.com/golem15/summercms/modules/pact`): `MCPSubject{Guard string; UserID uint; Scopes []string; ClientID string; TokenID uint}`; `MCPDispatch{Principal any; Credential any; Guards []string}`. `MCPPersonalTokenResolver` doc: a personal-token subject's UserID and TokenID are both set and TokenID identifies the token row. Framework convention (modules/pier/backend_guard.go): `subject.ClientID == "" && subject.TokenID != 0` identifies a personal-token subject.
- `models.APIToken.ID` is `uint` (column id, table `user_api_tokens`), the same type as `pact.MCPSubject.TokenID`.
- `MCPResolvePersonalToken` (same file) returns `pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: ..., TokenID: token.ID}` with ClientID empty.
- Test harness (package `user`, root of the plugin): `newAdminEnv(t)` skips under `-short` (adminHarnessDB: "requires testcontainers postgres"); `env.seedUser(t, label, nil)`, `env.mintUserToken(t, user, scopes...) (secret, *models.APIToken)` (stores the row), `env.gdb` (*gorm.DB), `env.h` (http.Handler of the app), `adminStamp()` (unique suffix). `TestUserMCPGuard` already looks up `guard` (`guards.Lookup("user")`) and `resolver` (`env.app.Lookup[pact.MCPPersonalTokenResolver]()`) and imports bouncer, models, pact, httptest, http, strings. Its subtest "dispatch credential against user.scope" shows how to run a route in-process with the dispatch marker (`bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: d.Guards, Principal: principal, Credential: d.Credential})` then `env.h.ServeHTTP`).
- Existing assertions that the credential has ID 0 (`mustDispatchCredential` in mcp_guard_test.go, line ~113; TestUserMCPGuardSmoke line ~121) use subjects without TokenID and without ClientID; they stay correct and must not be changed in meaning.
- Within the plugin, nothing reads the credential's ID (only `middleware.Scope` reads its scopes) and nothing saves it; the user-api throttle keys on the JWT subject, not the credential.
- The plugin keeps no CHANGELOG and no version file; releases are git tags only. No changelog entry is planned; the v0.1.14 tag needs the user's go-ahead and is out of scope.
- Commit style in this repo: `fix: <sentence>` subject plus a short prose body; fix commits include their tests and README edits; no trailers.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Personal-token MCP dispatch credential carries the token id, end to end (tests first, then fix, doc comment, README)</name>
<files>../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
<behavior>
- Subtest "dispatch credential carries the personal token id" (in TestUserMCPGuard): seed a user (label "mcp-token-id"), mint a personal token with scope read via env.mintUserToken, resolve its secret with resolver.MCPResolvePersonalToken (expect matched, nil error, ClientID empty, TokenID equal to the minted row's ID), dispatch that resolved subject with guard.MCPDispatch(ctx, subject, http.MethodGet); the credential is a *models.APIToken with ID equal to the minted token's ID, UserID equal to the user's, HasScope("read") true, HasScope("write") false. Running GET /_user/api/v1/token/me in-process with the bouncer dispatch marker carrying that principal and credential answers 200 and contains the user's email. Afterwards the count of user_api_tokens rows with that user_id is exactly 1 (counted through env.gdb with the models.APIToken model), proving the credential was not saved.
- Subtest "dispatch credential of an OAuth subject has no id": seed a user (label "mcp-oauth-id"), mint a personal token for it (only to obtain a real user_api_tokens id), then dispatch pact.MCPSubject with Guard "user", that user's ID, Scopes read, ClientID "oauth-client-" plus adminStamp(), and TokenID deliberately equal to the minted token's ID; the credential is a *models.APIToken with ID 0, UserID the user's and HasScope("read") true.
- Unchanged: a subject without ClientID and without TokenID still yields credential ID 0 (mustDispatchCredential and TestUserMCPGuardSmoke keep asserting this).
- RED expectation: before the fix the first subtest fails on the credential ID (0 instead of the token id); the OAuth subtest already passes (it guards against over-applying the fix).
</behavior>
<action>
All paths relative to /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user. Do not commit in this task (Task 2 commits after the full gates).
1. RED (mcp_guard_test.go): add the two subtests from the behavior block to TestUserMCPGuard, after the existing "dispatch credential against user.scope" subtest, with exactly those t.Run names. Reuse ctx, guard, resolver and env from the enclosing test; build the in-process request the same way the existing scope subtest does (type-assert d.Principal to *bouncer.Principal, wrap the request context with bouncer.WithDispatch, serve through env.h with httptest). Fail with t.Fatalf messages that print the got values (credential via %+v). Extend TestUserMCPGuard's doc comment with one clause saying the dispatch credential carries a personal token's id (TokenID) and keeps ID 0 for an OAuth subject. Update the doc comment of mustDispatchCredential to say it builds the credential for a read subject without a TokenID, which therefore has ID 0 and is not saved; keep its assertion unchanged. Run the RED command `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` and confirm the personal-token subtest fails on the ID and the OAuth subtest passes.
2. GREEN (classes/mcp_guard.go, MCPDispatch): keep the credential literal as is (UserID and the cloned Scopes); right after it, when subject.ClientID is empty, set the credential's ID to subject.TokenID (a personal-token subject; with TokenID 0 this leaves ID 0, matching the framework convention in pier's backend guard). For a subject with ClientID set leave ID 0: its TokenID belongs to the OAuth server plugin's token table. Do not load the token row from the database, do not copy other columns (Name, CollectionIDs, hash), do not change Guards or the principal. Keep the credential in memory only.
3. Doc comment of MCPDispatch: keep the first sentence (starting "MCPDispatch builds the principal") and the last one about the marker; replace the sentence in between that states the credential always has ID 0 with this text, wrapped to the file's comment width: "For a personal-token subject (ClientID empty) the credential's ID is the subject's TokenID, the user_api_tokens row the session was opened with, so a handler can record which token made a change; an OAuth subject's TokenID is the OAuth server's own token row, not one of this plugin's, and its credential keeps ID 0. The credential is never saved."
4. README.md, the "MCP guard and the `user_me` tool." paragraph (Features list): replace the parenthetical that follows "an in-memory token credential carrying the session's scopes" (it currently states the id is always 0) so the sentence reads: "A tool call runs the route with the user and an in-memory token credential carrying the session's scopes; for a session opened with a personal token the credential's ID is that token's id (the subject's `TokenID`), so a handler can record which token made a change, while an OAuth session's credential has ID 0. The credential is never saved." Leave the rest of the paragraph and the Testing section unchanged.
5. gofmt the two Go files, then run the verify command below until green.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && O=$(GOWORK=off go test -count=1 -v -run '^TestUserMCPGuard$/^dispatch_credential_(carries_the_personal_token_id|of_an_OAuth_subject_has_no_id)$' .) && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_carries_the_personal_token_id ' <<<"$O" && grep -qF -- '--- PASS: TestUserMCPGuard/dispatch_credential_of_an_OAuth_subject_has_no_id ' <<<"$O" && GOWORK=off go test -count=1 -run '^(TestUserMCPGuard|TestUserMCPGuardSmoke|TestUserMCPGuardClientIP)$' . && F=$(awk '/^func \(g \*MCPUserGuard\) MCPDispatch\(/,/^}/' classes/mcp_guard.go) && grep -qF 'subject.ClientID == ""' <<<"$F" && grep -qF 'subject.TokenID' <<<"$F" && ! grep -qF 'Find(' <<<"$F" && D=$(awk '/^\/\/ MCPDispatch builds the principal/,/^func \(g \*MCPUserGuard\) MCPDispatch\(/' classes/mcp_guard.go) && grep -qF 'TokenID' <<<"$D" && grep -qF 'keeps ID 0' <<<"$D" && grep -qF 'never saved' <<<"$D" && ! grep -qF 'has ID 0 and is never saved' <<<"$D" && R=$(grep -F '**MCP guard and the `user_me` tool.**' README.md) && grep -qF "the credential's ID is that token's id" <<<"$R" && grep -qF "OAuth session's credential has ID 0" <<<"$R" && ! grep -qF '(ID 0, never saved)' <<<"$R" && grep -qF 'tok.ID != 0' mcp_guard_test.go</automated>
</verify>
<done>Both new subtests exist and pass against Docker Postgres (not skipped); the personal-token subtest failed before the fix; MCPDispatch sets the credential ID from TokenID only for a subject without ClientID; doc comment and README paragraph describe the id and the OAuth exception; TestUserMCPGuardSmoke, TestUserMCPGuardClientIP and the existing TestUserMCPGuard subtests pass unchanged; nothing committed yet.</done>
</task>
<task type="auto">
<name>Task 2: Full gates and the single fix commit in sm-user-plugin (local only)</name>
<files>../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go, ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go, ../fonoteka.go/plugins/golem15/user/README.md</files>
<action>
In /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user:
1. Confirm `git status --short` lists only README.md, classes/mcp_guard.go and mcp_guard_test.go as modified (if anything else is dirty, another session is working here: stop and report instead of committing) and that HEAD is still 07196a4 or a descendant of it.
2. Run the gates: gofmt on the two Go files, `GOWORK=off go vet ./...`, `GOWORK=off go test -short -count=1 ./...`, then the full Docker-backed `GOWORK=off go test -count=1 ./...`. Fix any failure caused by Task 1's change before committing (do not weaken an existing test).
3. Stage exactly the three files by path (never `git add -A` or `git add .`) and commit on master with subject `fix: the MCP dispatch credential carries the personal token id` and this body (wrapped at ~72 columns): "MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the subject's TokenID when the subject came from a personal usr_ token (no ClientID), so a handler running under MCP dispatch can record which token made a change. An OAuth subject's TokenID names the OAuth server's token row, so its credential keeps ID 0. The credential is still never saved." No attribution trailers of any kind (user and project rule).
4. Do not push, do not tag (v0.1.14 needs the user's go-ahead), do not touch go.mod/go.sum. In the parent fonoteka.go repo do not stage or commit the plugins/golem15/user pointer; leave it showing as modified.
5. In the summary, record the new commit's short SHA, the gate results (and whether the Docker suite ran rather than skipped), and that the release tag and the fonoteka.go pointer bump are pending the user's go-ahead.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user && test -z "$(gofmt -l classes/mcp_guard.go mcp_guard_test.go)" && GOWORK=off go vet ./... && GOWORK=off go test -short -count=1 ./... && GOWORK=off go test -count=1 ./... && test -z "$(git status --short)" && C=$(git log -1 --format=%H --fixed-strings --grep='fix: the MCP dispatch credential carries the personal token id' master) && test -n "$C" && test "$C" = "$(git rev-parse master)" && git merge-base --is-ancestor 07196a4bcbfee1f200c17f66cad147a4c091cb26 "$C" && B=$(git log -1 --format=%B "$C") && ! grep -qi 'co-authored-by' <<<"$B" && git diff --quiet "$C^" "$C" -- go.mod go.sum && F=$(git diff --name-only "$C^" "$C") && test "$(LC_ALL=C sort <<<"$F" | tr '\n' ' ')" = "README.md classes/mcp_guard.go mcp_guard_test.go " && test -z "$(git tag --points-at "$C")" && test -z "$(git branch -r --contains "$C")" && ! git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go diff --cached --name-only | grep -qxF plugins/golem15/user && test -z "$(git -C /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go log --format=%h e36577a..HEAD -- plugins/golem15/user)"</automated>
</verify>
<done>sm-user-plugin master has one new local `fix:` commit (descendant of 07196a4) touching exactly README.md, classes/mcp_guard.go and mcp_guard_test.go, with no trailer; gofmt, vet, the -short suite and the full Docker-backed suite are green; the commit is neither pushed nor tagged; fonoteka.go has no staged or committed pointer change for plugins/golem15/user.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP endpoint -> guard provider | The framework MCP endpoint hands MCPDispatch a subject it resolved earlier (personal token via this plugin's resolver, or an OAuth token via the OAuth server plugin); the subject is trusted in-process data, not client input. |
| guard provider -> route handlers | The in-memory credential is placed on the request context through the bouncer dispatch marker; handlers of this plugin and of the consuming application read it. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-fsq-01 | Spoofing / Repudiation | MCPDispatch credential ID for OAuth subjects | medium | mitigate | The ID is copied only when subject.ClientID is empty, so an OAuth subject's TokenID (an OAuth server row id) is never presented as a user_api_tokens id and a change is never attributed to an unrelated personal token; the OAuth subtest uses a TokenID equal to a real user_api_tokens id and asserts credential ID 0. |
| T-fsq-02 | Tampering | in-memory credential with a real row id | medium | mitigate | With a non-zero ID a handler that saved the credential would overwrite the real token row (empty hash, session scopes). The plugin never saves it (only middleware.Scope reads scopes); the doc comment and README state it is never saved; the personal-token subtest asserts the user's user_api_tokens row count is unchanged after an in-process route run. Consumer handlers are documented to treat it as read-only. |
| T-fsq-03 | Information Disclosure | token id visible to in-process handlers | low | accept | The id is a non-secret row id that the HTTP user.api_token guard already exposes to the same handlers through the loaded row; no hash or secret is copied into the credential. |
| T-fsq-04 | Elevation of Privilege | scopes on the dispatch credential | low | mitigate | Scopes stay the session's (cloned from the subject) and are not reloaded from the token row; the existing user.scope subtest and the new subtest's HasScope("write") false check keep the scope boundary. |
| T-fsq-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs and no go.mod/go.sum change in this plan; Task 2's verify asserts go.mod and go.sum are unchanged in the commit. |
</threat_model>
<verification>
- Task 1 verify: the two new TestUserMCPGuard subtests are reported as PASS (not skipped) against testcontainers Postgres; TestUserMCPGuard, TestUserMCPGuardSmoke and TestUserMCPGuardClientIP pass; the MCPDispatch body gates the ID on an empty ClientID without a DB lookup; doc comment and README carry the new wording and no longer claim the ID is always 0.
- Task 2 verify: gofmt, GOWORK=off go vet, go test -short and the full Docker-backed go test are green on the committed tree; exactly one new local fix commit with exactly the three files, no trailer, untagged, unpushed; the fonoteka.go pointer is neither staged nor committed.
- Note for the summary: the new tests are Docker-backed and skip under -short (newAdminEnv), like the rest of TestUserMCPGuard.
</verification>
<success_criteria>
- A personal-token MCP session's tool call gives handlers a credential whose ID is the personal token's id (fsq-1).
- An OAuth MCP session's credential keeps ID 0 (fsq-2).
- Docs match the behaviour; one clean local `fix:` commit in sm-user-plugin, no push, no tag, no pointer bump (fsq-3).
</success_criteria>
<output>
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-fsq-sm-user-plugin-mcpdispatch-credential-ca/261028-fsq-SUMMARY.md` when done (planning docs stay in summercms.go; the orchestrator commits them).
</output>

View File

@@ -0,0 +1,88 @@
---
phase: quick-261028-fsq
plan: 01
quick_id: 261028-fsq
subsystem: sm-user-plugin (MCP guard)
tags: [mcp, api-tokens, attribution, sm-user-plugin]
status: complete
requires: []
provides:
- "MCPUserGuard.MCPDispatch credential carries the personal token id (subject.TokenID) when subject.ClientID is empty"
affects:
- "consuming applications' handlers reading bouncer.Credential under MCP dispatch"
tech-stack:
added: []
patterns:
- "personal-token subject = ClientID empty (framework convention from pier's backend guard)"
key-files:
created: []
modified:
- ../fonoteka.go/plugins/golem15/user/classes/mcp_guard.go
- ../fonoteka.go/plugins/golem15/user/mcp_guard_test.go
- ../fonoteka.go/plugins/golem15/user/README.md
decisions:
- "The MCP dispatch credential's ID comes from subject.TokenID only when ClientID is empty; an OAuth subject's TokenID (an OAuth server row) is never presented as a user_api_tokens id"
metrics:
duration: "~4 min"
completed: 2026-10-28
requirements: [fsq-1, fsq-2, fsq-3]
repo: sm-user-plugin (fonoteka.go/plugins/golem15/user)
commits: 1
plan_head_before: 07196a4bcbfee1f200c17f66cad147a4c091cb26
plan_head_after: a1b78b8
actuals:
tokens: 25113
tasks: 2
commits: 1
---
# Quick 261028-fsq Plan 01: MCP dispatch credential carries the personal token id Summary
`MCPUserGuard.MCPDispatch` in sm-user-plugin now sets the in-memory `*models.APIToken` credential's `ID` to `subject.TokenID` for a personal `usr_` token subject (`ClientID` empty). This lets handlers under MCP dispatch record which personal token made a change. OAuth subjects keep ID 0, and the credential is still never saved.
## What was done
- **classes/mcp_guard.go:** after the credential literal, `if subject.ClientID == "" { credential.ID = subject.TokenID }`. There is no DB lookup and no other column is copied. Guards and the principal are unchanged. The doc comment now describes the token id, the OAuth exception and that the credential is never saved.
- **mcp_guard_test.go:** two new `TestUserMCPGuard` subtests:
- `dispatch credential carries the personal token id` resolves a minted token through `MCPResolvePersonalToken` and dispatches it. It asserts the credential's ID, UserID and scopes (read yes, write no), then runs `GET /_user/api/v1/token/me` in-process with the dispatch marker and expects 200 with the user's email. It finally asserts the user still has exactly 1 `user_api_tokens` row, which shows the credential was not saved.
- `dispatch credential of an OAuth subject has no id` uses a subject with ClientID set and TokenID equal to a real `user_api_tokens` id, and asserts the credential ID is 0.
The doc comments of `TestUserMCPGuard` and `mustDispatchCredential` were updated. The meaning of the existing assertion did not change.
- **README.md:** the "MCP guard and the `user_me` tool." paragraph now describes the credential id and the OAuth exception.
## Commits (sm-user-plugin, master, local only)
- `a1b78b8`: fix: the MCP dispatch credential carries the personal token id (README.md, classes/mcp_guard.go, mcp_guard_test.go; no trailer)
## TDD gate
- RED: `GOWORK=off go test -count=1 -run '^TestUserMCPGuard$' .` failed. The personal-token subtest got credential `ID:0` instead of the token id 3, and the OAuth subtest passed, as the plan predicted. RED was not committed separately because the plan asks for one fix commit that includes its tests.
- GREEN: both subtests pass after the fix.
## Gate results
- gofmt on both Go files: clean
- `GOWORK=off go vet ./...`: clean
- `GOWORK=off go test -short -count=1 ./...`: ok
- `GOWORK=off go test -count=1 ./...` (full, Docker-backed testcontainers Postgres; ran, not skipped, root package 81.8s): ok
- Task 1 verify and Task 2 post-commit verify both passed. Task 2 checked: the commit is a descendant of 07196a4, it touches exactly 3 files, go.mod and go.sum are unchanged, there is no co-author trailer, no tag and no remote branch, the working tree is clean, and the fonoteka.go pointer is neither staged nor committed.
Note: the new subtests are Docker-backed and skip under `-short` (`newAdminEnv`), like the rest of `TestUserMCPGuard`.
## Pending (user's go-ahead)
- Push sm-user-plugin master and tag `v0.1.14`.
- Bump the `plugins/golem15/user` submodule pointer in fonoteka.go. It currently shows as modified and is left unstaged on purpose.
## Deviations from Plan
None. The plan was executed exactly as written. The pre-commit protected-branch assertion was overridden for this repo because the orchestrator's submodule guard and the plan both require the commit on sm-user-plugin `master`.
## Threat Flags
None. The change stays within the plan's threat model: T-fsq-01 is covered by the OAuth subtest, T-fsq-02 by the row-count assertion and the docs, and T-fsq-04 by the HasScope("write") false check.
## Self-Check: PASSED
- FOUND: classes/mcp_guard.go, mcp_guard_test.go, README.md (modified, committed)
- FOUND: commit a1b78b8 on sm-user-plugin master

View File

@@ -4,6 +4,7 @@ date: 2026-10-07
priority: medium
area: sm-faq-plugin
g15office: ylgfanlk
completed: 2026-10-28
---
Phase 16 ships the grzybyfunkcjonalne.pl FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) as static i18n/markdown content in the Nuxt app. Later blogs need editors to manage FAQs, so port `Golem15.Faq` 1:1 as its own `sm-faq-plugin` repo (see `.planning/notes/core-plugins-own-repos.md`): models, admin screens and a public read API by slug.

View File

@@ -10,6 +10,8 @@ files:
- ../sm-menu-plugin
- ../sm-faq-plugin
- ../sm-sitemap-plugin
completed: 2026-10-28
resolution: done in phase 19.3 (cooler module; sm-menu-plugin and sm-faq-plugin purge on save)
---
## Problem

View File

@@ -4,6 +4,8 @@ date: 2026-10-03
priority: medium
area: sm-sitemap-plugin
g15office: krx9geua
completed: 2026-10-28
resolution: dropped — sm-sitemap-plugin exists; phases 32 and 33 develop it
---
Phase 14 dropped sitemap for Płytarium, because Nuxt builds its own sitemap with `@nuxtjs/seo` and fonoteka registers no menu item types. The next port after Płytarium is a blog project (grzybyfunkcjonalne.pl or golem15.com), and it needs `Golem15.Sitemap` 1:1.

View File

@@ -4,6 +4,7 @@ date: 2026-10-19
priority: normal
area: sm-sitemap-plugin
g15office: w8kupqi3
completed: 2026-10-28
---
Chromium now warns on `/sitemap.xml` (seen on UAT during the Phase 17 UAT, test 7): "This site uses XSLT; that functionality is being removed from this browser very soon…". The sitemap plugin serves the PHP plugin's `<?xml-stylesheet type="text/xsl" href=".../sitemap.xsl"?>` and `/sitemap.xsl` to show a readable table in the browser.

View File

@@ -158,8 +158,10 @@ onMounted(async () => {
created.setAttribute('label', props.field.actionLabel || props.field.label || props.field.name)
created.setAttribute('busy-label', t('backend::lang.extension.busy'))
created.addEventListener(WIDGET_EVENT, listener)
mountPoint.value.append(created)
// The element is the action target before it is connected: an action
// its connectedCallback dispatches (inside append) is taken, not dropped.
element = created
mountPoint.value.append(created)
status.value = 'ready'
} catch {
if (!unmounted) {

View File

@@ -49,6 +49,7 @@ interface Options {
assets?: () => Promise<string[]>
source?: typeof SOURCE | null
routes?: Record<string, Route>
attachTo?: HTMLElement
}
function mountWidget(options: Options = {}) {
@@ -59,6 +60,7 @@ function mountWidget(options: Options = {}) {
})
const calls = mockApi(options.routes ?? {})
const wrapper = mount(WidgetField, {
attachTo: options.attachTo,
props: {
field: options.field ?? lookupField(),
modelValue: undefined,
@@ -367,6 +369,44 @@ describe('summer-action (D-05, D-07)', () => {
})
})
describe('an action from connectedCallback (CR-04)', () => {
it('posts the action an element dispatches while it is being connected', async () => {
const tag = 'acme-demo-eager'
if (!customElements.get(tag)) {
customElements.define(
tag,
class extends HTMLElement {
connectedCallback(): void {
this.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true, detail: { payload: { op: 'context' } } }))
}
},
)
}
const action = deferred()
const host = document.createElement('div')
document.body.append(host)
const { wrapper, calls } = mountWidget({
field: lookupField({ widget: tag }),
attachTo: host,
routes: { [`POST ${ACTION}`]: action.route },
})
await flushPromises()
const target = wrapper.find(tag).element as HTMLElement
expect(target.isConnected).toBe(true)
expect(target.hasAttribute('busy')).toBe(true)
const posts = requestsTo(calls, 'POST', ACTION)
expect(posts).toHaveLength(1)
const body = (await posts[0]!.json()) as Record<string, unknown>
expect(body.payload).toEqual({ op: 'context' })
const seen = results(target)
action.release({ body: { data: { message: '', fill: {} }, meta: {} } })
await flushPromises()
expect(target.hasAttribute('busy')).toBe(false)
expect(seen).toHaveLength(1)
wrapper.unmount()
})
})
describe('payload and data (summer-result)', () => {
const OK: Reply = { body: { data: { message: '', fill: {} }, meta: {} } }
const DATA_REPLY: Reply = {

View File

@@ -222,7 +222,7 @@ A hook or scope that has to read the database during a write should use the tran
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
Other plugins do not implement these hooks to follow your records; they listen for the record events every admin write fires after its commit: `pact.RecordSaved` from create, update, tree reorder (one per moved record) and relation-manager child create and update, `pact.RecordDeleted` from delete, each row of a bulk delete and relation-manager child delete. Record and bulk actions write through your own code and fire nothing unless that code calls `cabana.EmitRecordSaved` or `cabana.EmitRecordDeleted`. See [Record events](../plugins/extending.md#record-events).
Other plugins do not implement these hooks to follow your records; they listen for the record events every admin write fires after its commit: `pact.RecordSaved` from create, update, tree reorder (one per moved record), relation-manager child create and update, and a hasMany link or unlink (for the child, with its foreign key change, also when a parent's first save attaches the children created in its form), `pact.RecordDeleted` from delete, each row of a bulk delete, relation-manager child delete and the unlink of a child created in an unsaved parent's form. Record and bulk actions write through your own code and fire nothing unless that code calls `cabana.EmitRecordSaved` or `cabana.EmitRecordDeleted`. See [Record events](../plugins/extending.md#record-events).
### Form-only values and rules
@@ -600,7 +600,7 @@ Every field and tab of a compiled form has a stable name: the field key, and the
- A new tab is declared under `tabs:` and goes `after:` or `before:` an anchor tab, or after the last tab without one. `after: default` is allowed; `before: default` stops the boot, because the admin always renders the default tab first. A form without tabs that gains one renders its own fields under the default tab ("Basic"), then the new tab.
- `zone:` takes `tabs` (the default) only for now. `outside` and `secondary` (`pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`) are reserved and stop the boot with "is not supported yet".
- Extensions apply in plugin activation order (`Requires()` first). Several extensions anchored at the same point keep that order. An anchor may name a field or tab another plugin's extension added only when that plugin is in the contributor's `Requires()`; the fields of one document may anchor on each other in any order.
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, a hidden `relation-manager` field's relation routes (panel, link, unlink, child records) and a hidden `partial` field's partial answer 404 (a partial the list or preview header or another field still uses keeps rendering there), and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
- cabana compiles the order once at boot and sends the fields in that order. A form an extension lays out is regrouped by tab in first-appearance order, the default tab first, exactly as the admin renders tabs; a form no extension lays out (including one that only gets a `Save` hook) is compiled byte for byte as before. The admin renders what it receives and adds no ordering of its own, and `span` (left, right, full) keeps working next to inserted fields.
- Only top-level fields and tabs are placed; fields inside repeaters stay the owner's.
@@ -609,13 +609,14 @@ Every field and tab of a compiled form has a stable name: the field key, and the
An extension adds value fields (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` with listed options, `markdown`, `mltext`, `mltextarea`, `mlmarkdown` and plugin field types), `widget` fields and `partial` fields. Types that bind to the owner's model are refused at boot, `fileupload` included: an extension owns no attachments, so a plugin field type with `Upload` stores files instead.
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, `Values`, the submitted values of the extension's fields whose `context` allows the operation, and `Changes`, what the owner's save changed (old and new values, a translated value per locale), as the save's `pact.RecordSaved` will report it after the commit. `Save` runs on every create and update, with or without values.
- `required: true` on an extension field is checked on the submitted value, not as a rule on the owner's model, and only for administrators who see the extension: a create must send a value that is not null or blank (for a translatable field, the default locale's text), an update checks the value only when it is sent.
- A translatable field (`mltext`, `mltextarea`, `mlmarkdown`, a translatable plugin field type) reaches `Save` as a `map[string]string` of locale to text, and `Load` returns it the same way. A plugin field type's `Decode` runs before `Save` (its notices are kept, a `pact.FormFieldError` is a 422 on the field) and its `Load` after the extension's `Load`.
- A `Save` error rolls the whole save back, the owner's row included. `pact.AdminFormErrors` (field name to messages, phrase keys or text) answers 422 with those lines on the fields; any other error is logged and answered with the generic 500 body. `pact.AdminFormSaveResult.Notices` are save notices per field, shown on the field like a field type's notices; a notice may name an owner field.
- On show and in the save response, `Load` returns the values by field name; keys that are not the extension's fields are dropped.
### Permissions, widgets and partials
`Permissions` (backend permission codes some plugin registers) and `Visible` (asked per request) decide who sees the extension. An administrator who fails either gets none of its fields in the form schema or the record; a save drops their posted keys for it and does not call its `Save`, so stored values stay as they are. Its widget actions and partials answer 404 for them.
`Permissions` (backend permission codes some plugin registers) and `Visible` (asked per request) decide who sees the extension. An administrator who fails either gets none of its fields in the form schema or the record; a save drops their posted keys for it and does not call its `Save`, so stored values stay as they are. Its widget actions, partials and field type uploads answer 404 for them.
A `widget` field's tag uses the contributing plugin's prefix (`acme-extras-...`), its `action` names one of the extension's `Actions` (a name the owner already registers stops the boot), and its `fill` keys name value fields of the same extension. Its element script comes from one of the plugin's field types (loaded on every admin page) or from the plugin's own `pact.AdminClientAssets`, which cabana adds to the target form's assets. A `partial` field renders `_<path>.htm` from `Dir` in the contributing plugin's `AdminFS` through `PartialData`, with the same view model rules as `pact.AdminPartialData`; a partial name the owner already uses stops the boot.

View File

@@ -83,7 +83,7 @@ The SPA talks to the plugin element through attributes and two events, nothing e
The events:
- `summer-action` is dispatched by the element (with `bubbles` and `composed`) to ask for its action. Its optional `detail.payload` is any JSON value; the SPA posts it as `payload`. A detail without a `payload` property sends none.
- `summer-action` is dispatched by the element (with `bubbles` and `composed`) to ask for its action. Its optional `detail.payload` is any JSON value; the SPA posts it as `payload`. A detail without a `payload` property sends none. The SPA listens before it connects the element, so an action dispatched from `connectedCallback` (a first load, for example) is taken; one dispatched while `busy` is set is dropped, so wait for `busy` to clear before asking again.
- `summer-result` is dispatched by the SPA on the element after a successful action, with `detail` `{data, fill, message}`: the response data as returned (`undefined` when none), the fill object and the message. A failed action dispatches nothing.
The element never receives a token, a cookie, a Vue instance or a function, and it must not make requests of its own: the SPA owns HTTP and the admin session. Render server data with `textContent`, not `innerHTML`: `data` is the plugin's own answer, but it still came over the wire. The `acme-demo-lookup` fixture under `modules/cabana/testdata/extension/assets/js/lookup.js` is a complete reorder widget: a click sends the new id order as the payload and the returned item list is rendered from the `data` attribute and the `summer-result` event.

View File

@@ -48,7 +48,7 @@ Each listener names the plugin that owns it, so an error or a recovered panic in
The framework fires two events for every record written through the admin: `pact.RecordSaved` after a create or update, `pact.RecordDeleted` after a delete. They are fired as pointers, so a listener takes `*pact.RecordSaved` or `*pact.RecordDeleted`.
A `pact.RecordSaved` carries the admin controller ID (`Controller`), the record's morph type (`Model`, the model's `MorphName`, else its table name), its primary key as text (`ID`), the request locale, `Created` and `Changes`: one `pact.RecordChange` per changed attribute with its old and new value. A translated value is reported once per locale, with `Locale` set, so a translated slug change is visible too. A save that changed nothing still fires, with no changes. A `pact.RecordDeleted` carries `Controller`, `Model` and `ID`.
A `pact.RecordSaved` carries the admin controller ID (`Controller`), the record's morph type (`Model`, the model's `MorphName`, else its table name), its primary key as text (`ID`), the request locale (`Locale`, the primary tag of the administrator's request locale in the admin; values written to the row are the default content locale's, so key per-locale work on the changes), `Created` and `Changes`: one `pact.RecordChange` per changed attribute with its old and new value. A translated value is reported once per locale, with `Locale` set, so a translated slug change is visible too. A save that changed nothing still fires, with no changes. A `pact.RecordDeleted` carries `Controller`, `Model` and `ID`.
The events fire after the write commits, never for a write that rolled back, and synchronously before the response is written. A listener's error is logged and never fails the write. Admin creates and updates, deletes and bulk deletes, tree reorders (a moved record reports its parent and sort columns) and relation-manager child writes all fire them; a child's event names the controller that edits the child's model. A form extension's `Save` receives the same changes in `pact.AdminFormSave.Changes`, inside the save transaction.

View File

@@ -143,7 +143,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route. Immediately before the marker it stores a `pact.MCPCall` with `pact.WithMCPCall`. A route reads that call with `pact.MCPCallFrom`; any other request has none. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion, and the read/write ceiling is unchanged. The scopes an MCP authorization server advertises are `pact.MCPAdvertisedScopes`, which pier copies when `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty and otherwise replaces with `pier.PRMScopes`; OpenID Connect discovery stays `openid`, `profile` and `email`.
### Accepting admin tokens on plugin routes

View File

@@ -128,7 +128,9 @@ What pier does with it at boot:
Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (`oas_at_…`) is verified through the `pact.MCPTokenVerifier` the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the `backend` scope and a link the published `pact.MCPLinkedBackendResolver` confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The verifier also reports which administrator the user consented to (`pact.MCPGrant.BackendUserID`); while the link names anyone else, or when the grant records none, the grant has no backend subject, so a link that changes after the consent never re-targets it. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.
The `read` and `write` scopes are a ceiling: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it.
The `read` and `write` scopes are a ceiling, and that ceiling is unchanged: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it. `pact.MCPSubject.Scopes` on a call is the grant, not an implied expansion of `pact.FrontendTokenScope.Implies`.
Every tool call stores a `pact.MCPCall` with `pact.WithMCPCall` immediately before the dispatch marker. A route reads it with `pact.MCPCallFrom`; a request pier did not dispatch has none. `pact.MCPCall.Subject` is the subject resolved for that tool's guard, `pact.MCPCall.Tool` is the exposed tool name and `pact.MCPCall.SessionID` is the MCP session id, empty on the stateless path. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise.
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. An application has one such resolver; a subject it answers must carry the token's `TokenID`, which keys the session binding, and a subject without one is refused. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
@@ -136,7 +138,7 @@ Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Sess
## Protected resource metadata
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes (`read`, `write`, `ai`, `offline_access`, `backend`) and the bearer method. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
## Security notes

View File

@@ -39,7 +39,9 @@ func newServer() *wristband.Server {
> [!WARNING]
> Always set `wristband.Options.Resource` for your deployment. `wristband.DefaultOptions` leaves it empty, and while it is empty every authorization request that sends a `resource` parameter is refused with `invalid_target`.
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list: a client may request exactly those scopes (the example above drops `ai`, so a request for it is `invalid_scope`; a host that appends a scope of its own accepts it), and an empty list keeps the default `read`, `write`, `ai` and `offline_access`. The metadata document serves the configured values:
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list, one resolved slice. A non-empty list is used as given: the example above drops `ai`, so a request for it is `invalid_scope`, and `wristband.DefaultOptions` (`read`, `write`, `ai`, `offline_access`) still refuses `backend`. An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. OpenID Connect discovery stays `openid`, `profile` and `email`.
The MCP advertised set is that resolved list. The fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes (`pact.FrontendTokenScope`, including `pact.FrontendTokenScope.Implies`) come from the published `pact.MemberTokenScopeCatalog` and are filtered by the OAuth server plugin's key `golem15.oauthserver.mcp_scopes`. That key is the plugin's, not a framework config key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. The plugin publishes the list as `pact.MCPAdvertisedScopes`. pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty and otherwise keeps `pier.PRMScopes`. The read/write ceiling is unchanged. A route the endpoint runs reads the caller with `pact.MCPCallFrom`: `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise, and `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The metadata document serves the configured values:
```go src=modules/wristband/example_test.go#ExampleServer_Metadata
srv := newServer()
@@ -104,7 +106,7 @@ The metadata document advertises these paths under the issuer, so mount them at
The authorization endpoint sends the browser to `<issuer>/connect?request=<id>`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls:
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`;
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`, including `wristband.PendingRequestView.OfflineAccess` (the pending row's flag; `offline_access` is not a scope on the view and `wristband.Server.IssueCode` is unchanged);
- `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`;
- `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BgYjF4nQ.js"></script>
<script type="module" crossorigin src="./assets/index-BNWI_4R0.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Bakgr9jB.css">
</head>
<body>

View File

@@ -61,7 +61,7 @@ Use cases:
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
- Record events: every admin create and update fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them); a save that changed nothing fires with no changes. Delete and every row of a bulk delete fire `pact.RecordDeleted`; a tree reorder fires `pact.RecordSaved` for each moved record (parent and sort columns); relation-manager child writes fire both for the child, naming the controller that edits the child's model. A rolled-back write fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
- Record events: every admin create and update fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them); a save that changed nothing fires with no changes. Delete and every row of a bulk delete fire `pact.RecordDeleted`; a tree reorder fires `pact.RecordSaved` for each moved record (parent and sort columns); relation-manager child writes fire both for the child, naming the controller that edits the child's model; a hasMany link or unlink, and the attach of a child created in an unsaved parent's form session, fire `pact.RecordSaved` for the child with its foreign key change, and a session-created child that is unlinked again fires `pact.RecordDeleted`. A rolled-back write fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.

View File

@@ -34,8 +34,10 @@ type CRUDService struct {
// fields. Nil when the translate plugin is not mounted.
writer TranslationWriter
// app receives the record events of the service's writes
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none.
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none. reg names
// the controller of a related record a save rewrites; it may be nil.
app *backpack.App
reg *Registry
}
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
@@ -1351,6 +1353,12 @@ func mergedRules(ctx context.Context, cc *CompiledController, model any, op stri
return out
}
for _, field := range cc.Form.Fields {
// D-91: a form extension's field is not a column of this model; its
// required is checked on the lifted value (liftExtensionValues), and
// only for administrators who see the extension.
if cc.extensionOf(field.Name) != nil {
continue
}
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill. A virtual
// field is checked against its submitted value.

View File

@@ -114,7 +114,7 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
if err != nil {
return lifecycleFailure(cc, err)
}
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr}
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr, app: s.app, reg: s.reg}
parent := &relationParent{model: target, id: pkUint(target)}
applied := make([]uint, 0, len(rows))
for _, row := range rows {
@@ -224,10 +224,9 @@ func (s RelationService) applyRelationBinding(ctx context.Context, tx *gorm.DB,
if fk, ok := structFieldValue(child, cr.Contract.ForeignKey); !ok || !fk.IsNil() {
return true, nil
}
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
return false, err
}
return true, tx.WithContext(ctx).Save(child).Error
return true, s.saveLinkedChild(ctx, tx, child, func() error {
return setModelColumn(child, cr.Contract.ForeignKey, parent.id)
})
}
linked, err := pendingRelationIDs(tx, cr, parent.id, []uint{id})
if err != nil || len(linked) == 0 {

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
@@ -28,7 +29,8 @@ import (
// the record transaction fails after Upload or the element stops waiting;
// Upload stores under a name that is safe to leave behind. It answers
// 201 {data: {url}}; a pact.FormFieldError is a 422 on the field, any other
// Upload error the opaque 500.
// Upload error the opaque 500. A form extension's field answers 404 to an
// administrator who does not see the extension.
func (s *service) fieldUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
@@ -49,6 +51,16 @@ func (s *service) fieldUpload(w http.ResponseWriter, r *http.Request) {
writeNotFound(w, r)
return
}
// D-91: a form extension's field exists only for an administrator
// who sees the extension (its Permissions and Visible), as on its
// widget actions and partials.
if ext := cc.extensionOf(field.Name); ext != nil {
principal, _ := bouncer.User(r.Context())
if !extensionVisible(r.Context(), principal, ext) {
writeNotFound(w, r)
return
}
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)

View File

@@ -158,6 +158,7 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []
layout = true
}
}
ownerFields := cc.Form.Fields
form := newFormLayout(cc.Form.Fields, requires)
for i, c := range contributions {
ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}}
@@ -193,7 +194,7 @@ func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []
}
}
}
dropHiddenFields(cc, form.hidden)
dropHiddenFields(cc, ownerFields, form.hidden)
return nil
}
@@ -620,11 +621,29 @@ func checkFieldReferences(fields []FormField, field FormField) error {
}
// dropHiddenFields removes the fields extensions hide from the owner's
// bindings, so show and save leave them out too.
func dropHiddenFields(cc *CompiledController, hidden map[string]string) {
// bindings, so show and save leave them out too. A hidden relation-manager
// field takes its relation (panel, link, unlink, child and deferred routes)
// with it, and a hidden partial field its path from the form partials, so
// its endpoints answer 404 like the field's absence from the schema. owner
// is the owner's field list before the extensions laid it out.
func dropHiddenFields(cc *CompiledController, owner []FormField, hidden map[string]string) {
if len(hidden) == 0 {
return
}
// The maps are copied: the owner's compiled maps may be shared.
if len(cc.Relations) > 0 {
relations := make(map[string]*CompiledRelation, len(cc.Relations))
for name, cr := range cc.Relations {
if cr != nil {
if _, gone := hidden[cr.fieldName]; gone {
continue
}
}
relations[name] = cr
}
cc.Relations = relations
}
dropHiddenPartials(cc, owner, hidden)
writable := make([]WritableField, 0, len(cc.Writable))
for _, field := range cc.Writable {
if _, gone := hidden[field.Name]; !gone {
@@ -640,6 +659,51 @@ func dropHiddenFields(cc *CompiledController, hidden map[string]string) {
}
}
// dropHiddenPartials takes the paths of hidden partial fields out of the
// form partials, and out of the partials when nothing else renders them (a
// field still on the form, the list or the preview header partial).
func dropHiddenPartials(cc *CompiledController, owner []FormField, hidden map[string]string) {
gone := map[string]bool{}
for _, field := range owner {
if _, ok := hidden[field.Name]; ok && field.Type == "partial" {
gone[field.Path] = true
}
}
if len(gone) == 0 {
return
}
kept := map[string]bool{}
if cc.Form != nil {
for _, field := range cc.Form.Fields {
if field.Type == "partial" && cc.extensionOf(field.Name) == nil {
kept[field.Path] = true
}
}
}
header := map[string]bool{}
if cc.List != nil && cc.List.HeaderPartial != "" {
header[cc.List.HeaderPartial] = true
}
if cc.Form != nil && cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" {
kept[cc.Form.preview.HeaderPartial] = true
}
formPartials := make(map[string]bool, len(cc.formPartials))
for name, ok := range cc.formPartials {
if gone[name] && !kept[name] {
continue
}
formPartials[name] = ok
}
partials := make(map[string]*compiledPartial, len(cc.partials))
for name, compiled := range cc.partials {
if gone[name] && !kept[name] && !header[name] {
continue
}
partials[name] = compiled
}
cc.formPartials, cc.partials = formPartials, partials
}
// tabGroup is one tab of a form being laid out: its raw tab key ("" for the
// default tab) and its fields in order.
type tabGroup struct {
@@ -1161,7 +1225,8 @@ type extensionLift struct {
// extension keeps the values of its value fields whose context allows the
// operation: a translatable one as a map of locale to text (a string is the
// default locale's text), any other built-in type as a single JSON value, a
// plugin field type as sent.
// plugin field type as sent. A required field of a visible extension is
// checked here, never against the owner's model (mergedRules skips it).
func liftExtensionValues(ctx context.Context, in extensionLift, body map[string]any) (extensionValues, error) {
out := extensionValues{}
cc := in.CC
@@ -1176,14 +1241,21 @@ func liftExtensionValues(ctx context.Context, in extensionLift, body map[string]
for _, ext := range cc.formExtensions {
for _, name := range ext.fields {
value, present := body[name]
if !present {
continue
}
delete(body, name)
if !in.Visible[ext] || !ext.valueField(name) || !contextAllows(cc, name, in.Op) {
continue
}
field := ext.own[name]
// A required extension field must be sent on create; on update
// only a sent value is checked, so a partial save keeps the
// stored one. Blank is null or whitespace, for a translatable
// field the default locale's text.
if !present {
if field.Required && in.Op == "create" {
details[name] = []string{"The " + name + " field is required."}
}
continue
}
if isMLField(field) {
if !resolved {
var err error
@@ -1197,10 +1269,17 @@ func liftExtensionValues(ctx context.Context, in extensionLift, body map[string]
details[name] = []string{message}
continue
}
if def, sent := texts[locales.def]; field.Required && ((sent && blankValue(def)) || (!sent && in.Op == "create")) {
details[name] = []string{"The " + name + " field is required."}
continue
}
value = texts
} else if field.fieldType == nil && nestedValue(value) {
details[name] = []string{"The " + name + " field must be a single value."}
continue
} else if field.Required && blankValue(value) {
details[name] = []string{"The " + name + " field is required."}
continue
}
if out[ext] == nil {
out[ext] = map[string]any{}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
@@ -533,3 +534,143 @@ func TestFormExtensionSave(t *testing.T) {
}
})
}
// TestFormExtensionRequired (CR-01): a required extension field is checked
// on the lifted value, never as a rule on the owner's model. An administrator
// who sees the extension must send it on create and may not blank it; one who
// does not see it saves the host form freely.
func TestFormExtensionRequired(t *testing.T) {
store := &feStore{}
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n c:\n type: text\n required: true\n after: a\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Permissions: []string{"acme.extra.manage"}, Load: store.load, Save: store.save},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
create := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder {
t.Helper()
return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPost, "record", "", body)
}
for name, body := range map[string]map[string]any{
"missing": {"a": "A"},
"blank": {"a": "A", "c": " "},
"null": {"a": "A", "c": nil},
} {
rec := create(superUser(), body)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") {
t.Fatalf("%s: status=%d body=%s", name, rec.Code, rec.Body.String())
}
}
rec := create(superUser(), map[string]any{"a": "A", "c": "C"})
if rec.Code != http.StatusCreated {
t.Fatalf("create with c: status=%d body=%s", rec.Code, rec.Body.String())
}
created := pgFTDecode(t, rec)
idText := ""
switch v := created.Data["id"].(type) {
case float64:
idText = strconv.FormatFloat(v, 'f', -1, 64)
case string:
idText = v
}
if idText == "" {
t.Fatalf("create data=%v", created.Data)
}
// An update without c keeps the stored value; a blank c is refused.
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A2"}); rec.Code != http.StatusOK {
t.Fatalf("partial update: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A3", "c": ""}); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("blank update: status=%d body=%s", rec.Code, rec.Body.String())
}
// The extension is hidden from editor: required does not apply to them.
rec = create(editor, map[string]any{"a": "E"})
if rec.Code != http.StatusCreated {
t.Fatalf("editor create: status=%d body=%s", rec.Code, rec.Body.String())
}
if rec := feCall(t, env.svc, editor, "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "E2", "c": ""}); rec.Code != http.StatusOK {
t.Fatalf("editor update: status=%d body=%s", rec.Code, rec.Body.String())
}
}
// TestFormExtensionUpload (CR-02): the field upload route of an extension's
// plugin field type answers 404 to an administrator who does not see the
// extension, and its Upload never runs for them.
func TestFormExtensionUpload(t *testing.T) {
var uploads int
fileType := pact.FormFieldType{
Name: "extrafile", Element: "acme-extra-file", Scripts: []string{"assets/js/extra.js"},
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return pact.FormFieldDecoded{Value: in.Value}, nil
},
Upload: func(_ context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
uploads++
return pact.FormFieldUploadResult{URL: "/media/" + in.Filename}, nil
},
}
extra := feContributor{
id: "acme.extra",
fsys: fstest.MapFS{
"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n img:\n type: extrafile\n after: a\n")},
"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")},
},
types: []pact.FormFieldType{fileType},
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Permissions: []string{"acme.extra.manage"}, Save: feNoopSave},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
seed := fePost{A: "A0"}
if err := env.db.Create(&seed).Error; err != nil {
t.Fatal(err)
}
upload := func(principal *bouncer.Principal, id string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "a.png")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("png")); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-Requested-With", "XMLHttpRequest")
for name, value := range map[string]string{"vendor": "acme", "plugin": "blog", "controller": "posts", "id": id, "field": "img"} {
req.SetPathValue(name, value)
}
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
rec := httptest.NewRecorder()
requireAjax(env.svc.fieldUpload)(rec, req)
return rec
}
id := strconv.FormatUint(uint64(seed.ID), 10)
for _, target := range []string{"0", id} {
if rec := upload(superUser(), target); rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), "/media/a.png") {
t.Fatalf("visible upload to %s: status=%d body=%s", target, rec.Code, rec.Body.String())
}
}
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
before := uploads
for _, target := range []string{"0", id} {
if rec := upload(editor, target); rec.Code != http.StatusNotFound {
t.Fatalf("invisible upload to %s: status=%d body=%s", target, rec.Code, rec.Body.String())
}
}
if uploads != before {
t.Fatalf("Upload ran for an administrator who does not see the extension")
}
}

View File

@@ -545,3 +545,49 @@ func TestFormExtensionWidgetsAndPartials(t *testing.T) {
}
})
}
// TestFormExtensionHideEndpoints (WR-07): a hidden relation-manager field
// takes its relation routes with it, and a hidden partial field its partial,
// while the owner's form without Hide keeps both.
func TestFormExtensionHideEndpoints(t *testing.T) {
t.Run("relation-manager", func(t *testing.T) {
hide := feExt("acme.extra", nil, "acme.events.pages", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"notes"} })
reg, err := feCompile(rePlugin{}, hide)
if err != nil {
t.Fatalf("compile: %v", err)
}
cc, _ := reg.Get("acme.events.pages")
if _, err := relationOf(cc, "notes"); err == nil {
t.Fatal("the hidden relation-manager field's relation is still routed")
}
plain, err := feCompile(rePlugin{})
if err != nil {
t.Fatal(err)
}
cc, _ = plain.Get("acme.events.pages")
if _, err := relationOf(cc, "notes"); err != nil {
t.Fatalf("the owner's relation without Hide: %v", err)
}
})
t.Run("partial", func(t *testing.T) {
owner := feOwnerPlugin{id: "acme.blog", fields: feFields("a") + " summary:\n type: partial\n path: summary\n"}
render := func(plugins ...party.Plugin) int {
t.Helper()
reg, err := feCompile(plugins...)
if err != nil {
t.Fatalf("compile: %v", err)
}
rec := httptest.NewRecorder()
(&service{reg: reg}).partial(rec, feRequest(superUser(), http.MethodGet, "/", nil, map[string]string{"name": "summary"}))
return rec.Code
}
if code := render(owner); code != http.StatusOK {
t.Fatalf("owner partial status=%d", code)
}
hide := feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"summary"} })
if code := render(owner, hide); code != http.StatusNotFound {
t.Fatalf("hidden partial status=%d, want 404", code)
}
})
}

View File

@@ -1127,7 +1127,7 @@ func (s *service) crud() (CRUDService, error) {
if err != nil {
return CRUDService{}, err
}
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app}
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app, reg: s.reg}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w

View File

@@ -62,17 +62,24 @@ func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *Co
if app == nil || app.Events == nil {
return
}
locale, _ := towel.Locale(ctx)
EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
Controller: controllerID(cc),
Model: recordMorphType(tx, model),
ID: recordIDText(model),
Locale: locale,
Locale: eventLocale(ctx),
Created: created,
Changes: changes,
})
}
// eventLocale is the request locale an admin record event reports: the
// primary tag of the context locale (a raw Accept-Language header becomes
// its first tag), "" when the request has none.
func eventLocale(ctx context.Context) string {
locale, _ := towel.Locale(ctx)
return primaryLanguage(locale)
}
// emitRecordDeleted fires the admin delete of model through controller cc
// after tx commits.
func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) {

View File

@@ -18,6 +18,7 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
@@ -465,6 +466,86 @@ func TestRecordEvents(t *testing.T) {
}
})
t.Run("hasMany link and unlink", func(t *testing.T) {
// The link candidates exclude children pending in a form session.
if err := env.db.AutoMigrate(&lagoon.DeferredBinding{}); err != nil {
t.Fatal(err)
}
loose := reNote{Body: "loose"}
if err := env.db.Create(&loose).Error; err != nil {
t.Fatal(err)
}
rel := env.relations(t)
noteID := strconv.FormatUint(uint64(loose.ID), 10)
if _, err := rel.Link(ctx, cc, "notes", homeID, RelationMutationInput{IDs: []any{float64(loose.ID)}}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
want := pact.RecordSaved{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: noteID, Locale: "pl",
Changes: []pact.RecordChange{{Attribute: "page_id", Old: nil, New: homeID}}}
if !reflect.DeepEqual(ev, want) {
t.Fatalf("link event = %+v\nwant %+v", ev, want)
}
if _, err := rel.Unlink(ctx, cc, "notes", homeID, RelationMutationInput{IDs: []any{float64(loose.ID)}}); err != nil {
t.Fatal(err)
}
ev = oneSaved(t, env.listener)
want.Changes = []pact.RecordChange{{Attribute: "page_id", Old: homeID, New: nil}}
if !reflect.DeepEqual(ev, want) {
t.Fatalf("unlink event = %+v\nwant %+v", ev, want)
}
})
t.Run("deferred children", func(t *testing.T) {
if err := env.db.AutoMigrate(&lagoon.DeferredBinding{}); err != nil {
t.Fatal(err)
}
rel := env.relations(t)
rel.SessionKey = "re-events-session-key-0123456789abcdef"
// A child created in the session and unlinked again is deleted:
// its RecordSaved{Created} is followed by a RecordDeleted.
dropped, err := rel.CreateChild(ctx, cc, "notes", 0, RecordInput{Body: map[string]any{"body": "dropped"}})
if err != nil {
t.Fatal(err)
}
droppedID := reID(t, dropped.Data)
if ev := oneSaved(t, env.listener); !ev.Created {
t.Fatalf("deferred create event = %+v", ev)
}
if _, err := rel.Unlink(ctx, cc, "notes", 0, RelationMutationInput{IDs: []any{float64(droppedID)}}); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
wantDel := []pact.RecordDeleted{{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: strconv.FormatUint(uint64(droppedID), 10)}}
if len(saved) != 0 || !reflect.DeepEqual(deleted, wantDel) {
t.Fatalf("deferred unlink events: saved %+v deleted %+v", saved, deleted)
}
// A child created in the session is attached by the parent's first
// save: it gets its RecordSaved with the new key.
kept, err := rel.CreateChild(ctx, cc, "notes", 0, RecordInput{Body: map[string]any{"body": "kept"}})
if err != nil {
t.Fatal(err)
}
keptID := reID(t, kept.Data)
env.listener.take()
page, err := crud.Create(ctx, cc, RecordInput{Body: map[string]any{"title": "Deferred"}, SessionKey: rel.SessionKey})
if err != nil {
t.Fatal(err)
}
pageID := reID(t, page)
saved, _ = env.listener.take()
var child *pact.RecordSaved
for i := range saved {
if saved[i].Model == "cabana_re_notes" {
child = &saved[i]
}
}
want := []pact.RecordChange{{Attribute: "page_id", Old: nil, New: pageID}}
if len(saved) != 2 || child == nil || child.ID != strconv.FormatUint(uint64(keptID), 10) || child.Created || !reflect.DeepEqual(child.Changes, want) {
t.Fatalf("deferred commit events = %+v, want the page and the attached child", saved)
}
})
t.Run("delete", func(t *testing.T) {
victim := rePage{Title: "Victim", Slug: "victim"}
if err := env.db.Create(&victim).Error; err != nil {

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
)
// The D-92 record-event diff without a database: the snapshot taken before
@@ -267,3 +268,22 @@ func TestRecordEventDiffValues(t *testing.T) {
}
})
}
// TestRecordEventLocale (WR-02): an admin record event reports the primary
// tag of the request locale, never a raw Accept-Language header.
func TestRecordEventLocale(t *testing.T) {
for in, want := range map[string]string{
"": "",
"pl": "pl",
"pl-PL,pl;q=0.9,en-US;q=0.8": "pl-PL",
" en ; q=1": "en",
} {
ctx := context.Background()
if in != "" {
ctx = towel.WithLocale(ctx, in)
}
if got := eventLocale(ctx); got != want {
t.Errorf("eventLocale(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -1002,15 +1002,15 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
for i := 0; i < holder.Elem().Len(); i++ {
related := holder.Elem().Index(i).Addr().Interface()
if cr.hasMany() {
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return 0, lifecycleFailure(cc, err)
}
// A link always changes the key: it adopts only children whose
// key is NULL.
if err := clearColumns(related, cr.Contract.ClearOnChange, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
err := s.saveLinkedChild(ctx, tx, related, func() error {
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
return err
}
// A link always changes the key: it adopts only children
// whose key is NULL.
return clearColumns(related, cr.Contract.ClearOnChange, nil)
})
if err != nil {
return 0, lifecycleFailure(cc, err)
}
linked++
@@ -1130,6 +1130,12 @@ func (s RelationService) unlinkDeferred(ctx context.Context, tx *gorm.DB, cc *Co
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
return 0, lifecycleFailure(cc, err)
}
// CreateChild announced the child; its removal is announced too.
EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{
Controller: s.reg.controllerForRecord(child),
Model: recordMorphType(tx, child),
ID: recordIDText(child),
})
}
return len(bound), nil
}
@@ -1307,13 +1313,13 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
}
for i := 0; i < holder.Elem().Len(); i++ {
child := holder.Elem().Index(i).Addr().Interface()
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := clearColumns(child, cr.Contract.ClearOnChange, nil); err != nil {
return 0, lifecycleFailure(cc, err)
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
err := s.saveLinkedChild(ctx, tx, child, func() error {
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
return err
}
return clearColumns(child, cr.Contract.ClearOnChange, nil)
})
if err != nil {
return 0, lifecycleFailure(cc, err)
}
}
@@ -1331,6 +1337,36 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
return len(rows), nil
}
// saveLinkedChild applies change to a hasMany child (its foreign key and
// ClearOnChange columns), saves it and fires its RecordSaved with what
// changed after tx commits (D-92): link, unlink and the deferred commit
// rewrite children through it.
func (s RelationService) saveLinkedChild(ctx context.Context, tx *gorm.DB, child any, change func() error) error {
events := s.app != nil && s.app.Events != nil
var before *recordSnapshot
if events {
var err error
if before, err = takeRecordSnapshot(ctx, tx, nil, child, nil); err != nil {
return err
}
}
if err := change(); err != nil {
return err
}
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
return err
}
if !events {
return nil
}
changes, err := recordChanges(ctx, tx, nil, child, before, nil)
if err != nil {
return err
}
s.emitChildSaved(ctx, tx, child, false, changes)
return nil
}
// lockPivotRows loads this parent's pivot rows for the related ids FOR
// UPDATE, in related id order.
func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) {

View File

@@ -12,7 +12,6 @@ import (
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -487,12 +486,11 @@ func (s RelationService) emitChildSaved(ctx context.Context, tx *gorm.DB, child
if s.app == nil || s.app.Events == nil {
return
}
locale, _ := towel.Locale(ctx)
EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{
Controller: s.reg.controllerForRecord(child),
Model: recordMorphType(tx, child),
ID: recordIDText(child),
Locale: locale,
Locale: eventLocale(ctx),
Created: created,
Changes: changes,
})
@@ -670,7 +668,7 @@ func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc
// X-Child-Session-Key session, in.SessionKey) to the saved child inside the
// child's transaction, then rechecks the child's file limits (D-17).
func (s RelationService) commitChildFiles(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, child any, op string, in RecordInput) error {
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr}
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr, app: s.app, reg: s.reg}
return crud.commitDeferred(ctx, tx, cr.child, child, op, RecordInput{SessionKey: in.SessionKey})
}

View File

@@ -336,7 +336,7 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
if s.app != nil && s.app.Events != nil {
morph := recordMorphType(tx, proto)
for _, ev := range reorderEvents(cc, morph, locked.Nodes, in.Nodes) {
ev.Locale, _ = towel.Locale(lctx)
ev.Locale = eventLocale(r.Context())
EmitRecordSaved(lctx, s.app, tx, ev)
}
}

View File

@@ -26,9 +26,9 @@ Use cases:
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed).
- Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token.
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.FrontendTokenScope.Implies` is only `""`, `read` or `write`, for a later OAuth MCP check; empty means the scope is not implied, and the route middleware is still what enforces a member scope. The plugin that owns member tokens publishes the compiled list as `pact.MemberTokenScopeCatalog` (`pact.MemberTokenScopeCatalog.MemberTokenScopes`). `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
- Frontend permission codes: `pact.FrontendPermission` and `pact.HasFrontendPermissions`, the frontend counterpart of `pact.HasPermissions`, so a plugin guarding a route with a permission code also declares that code for the user plugin's permission editors.
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list.
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPAdvertisedScopes` (`pact.MCPAdvertisedScopes.MCPScopesSupported`) is the scope list that verifier publishes for protected-resource metadata, separate from `pact.MCPTokenVerifier` so that interface stays `MCPVerifyToken` and `MCPResource`; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list. An in-process tool call carries a `pact.MCPCall`, stored with `pact.WithMCPCall` and read with `pact.MCPCallFrom` (absent on a plain HTTP request).
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's) and `pact.AdminFormExtension` (a plugin places fields and tabs in another controller's form, before or after the owner's, and loads and saves their values itself inside the record's save transaction), and `pact.AdminPartialData` (the curated view model a partial template renders).
@@ -36,7 +36,7 @@ Use cases:
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`).
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the content locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the request locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
@@ -124,8 +124,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasPermissions` | Declares backend permissions. |
| `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. |
| `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. |
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label` and `Description`. |
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label`, `Description` and `Implies` (`""`, `read` or `write`; empty means not implied). The route middleware enforces the scope; `Implies` is data for a later OAuth MCP check and is not an expansion by itself. |
| `pact.HasFrontendTokenScopes` | Declares member token scopes, the frontend counterpart of `pact.HasTokenScopes`; compiled by the plugin that owns member tokens, never by the admin token catalog. |
| `pact.MemberTokenScopeCatalog` | The compiled member-token scope list, published on the application. `pact.MemberTokenScopeCatalog.MemberTokenScopes` returns `[]pact.FrontendTokenScope`. |
| `pact.ValidTokenScopeName` | Reports whether a scope name is valid (`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`); shared by both token catalogs. |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
@@ -165,9 +166,13 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`), the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`) and `Writes`, set when the prompt directs tools that are not read-only so it is listed only for an identity that may write (zero value: offered to read-only grants too). |
| `pact.MCPGuard` | A guard provider the owner of a route guard publishes: its name, whether a middleware list is token-guarded by it, the scopes and permissions a middleware list requires (`pact.MCPRequirements`), whether a subject may call a tool, and the pre-resolved principal for one in-process call (`pact.MCPDispatch`). |
| `pact.MCPGuards` | The registry of guard providers published on the application; `pact.NewMCPGuards` creates it, `Register` refuses an empty or duplicate name with a `pact:` error naming the guard, `Lookup` finds one and `Names` lists them sorted. Safe for concurrent use. |
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and personal token id. |
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and token id. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion. |
| `pact.MCPCall` | One in-process MCP tool call: `pact.MCPCall.Subject`, `pact.MCPCall.SessionID` (empty on the stateless path) and `pact.MCPCall.Tool`. |
| `pact.WithMCPCall` | Stores a `pact.MCPCall` on a context. A nil context is returned as nil. |
| `pact.MCPCallFrom` | Returns the `pact.MCPCall` on a context. The boolean is false when the context is nil or carries none, including a plain HTTP request. |
| `pact.MCPDispatch` | A guard provider's answer for one call: the principal (as `any`), an optional credential and the bouncer guard names that honour the dispatch marker. |
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry and, for a `backend` grant, `BackendUserID`, the backend account the consent showed) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry and, for a `backend` grant, `BackendUserID`, the backend account the consent showed) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. The interface is only `MCPVerifyToken` and `MCPResource`. |
| `pact.MCPAdvertisedScopes` | Optional second interface on that verifier. `pact.MCPAdvertisedScopes.MCPScopesSupported` is the MCP scope list pier copies into protected-resource metadata when the slice is non-empty. |
| `pact.MCPPersonalTokenResolver` | Published by the one plugin whose personal tokens may open an MCP session (one resolver per application): resolves a raw token into a `pact.MCPSubject` whose `TokenID` identifies the token row (it keys the session binding; a subject without one is refused); false when the prefix is not its own, which the endpoint answers as unauthorized. |
| `pact.MCPLinkedBackendResolver` | Published by the plugin that owns the frontend-to-backend account link: the `pact.LinkedBackendUser` (id, login, display name) of a frontend user, false when unlinked. |
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. `TokenID` and `ClientID` narrow a revoked event: a token id ends only the sessions that personal token opened (subject with that `TokenID` and no `ClientID`), a client id only that OAuth client's sessions, neither every session of the subject; other kinds ignore them. |

View File

@@ -481,7 +481,9 @@ const (
// only when that plugin is in the contributor's Requires().
//
// Hide lists owner fields the form leaves out for everyone: they leave the
// schema, show and save, and a tab without fields disappears. Permissions
// schema, show and save, a hidden relation-manager field's relation routes
// and a hidden partial field's record render answer 404, and a tab without
// fields disappears. Permissions
// are backend permission codes an administrator needs to see the extension's
// fields; Visible, when set, is asked per request as well. An administrator
// who fails either gets none of the fields in the schema or the record, and a
@@ -815,11 +817,14 @@ type HasTokenScopes interface {
// member token screens show. Label and Description are phrase keys
// (containing "::") or literal text. It is the frontend counterpart of
// TokenScope and carries no permission patterns or method filter: the route
// middleware alone decides what a member scope allows.
// middleware is what enforces a member scope. Implies is only "", "read" or
// "write", for a later OAuth MCP check; the zero value means the scope is
// not implied. This package does not perform that check.
type FrontendTokenScope struct {
Name string
Label string
Description string
Implies string
}
// HasFrontendTokenScopes is implemented by plugins that declare member

View File

@@ -139,6 +139,40 @@ type MCPSubject struct {
TokenID uint
}
// mcpCallKey is the unexported context key of an MCPCall. A plain HTTP
// request never carries one.
type mcpCallKey struct{}
// MCPCall is one in-process MCP tool call. Subject is the subject pier
// resolved for the tool's guard, not an expansion of its scopes. SessionID
// is the MCP session id, empty on the stateless path and when the call was
// served without a session. Tool is the exposed tool name.
type MCPCall struct {
Subject MCPSubject
SessionID string
Tool string
}
// WithMCPCall stores call on ctx. A nil ctx is returned as nil: the
// dispatch context is never nil, and this does not invent a background
// context.
func WithMCPCall(ctx context.Context, call MCPCall) context.Context {
if ctx == nil {
return nil
}
return context.WithValue(ctx, mcpCallKey{}, call)
}
// MCPCallFrom returns the MCPCall on ctx. ok is false when ctx is nil or
// carries none, including a plain HTTP request.
func MCPCallFrom(ctx context.Context) (MCPCall, bool) {
if ctx == nil {
return MCPCall{}, false
}
call, ok := ctx.Value(mcpCallKey{}).(MCPCall)
return call, ok
}
// MCPDispatch is a guard provider's answer for one in-process call: the
// principal (a *bouncer.Principal, carried as any so pact stays free of
// bouncer), the optional credential stored next to it, and the bouncer
@@ -224,6 +258,22 @@ type MCPTokenVerifier interface {
MCPResource() string
}
// MCPAdvertisedScopes is implemented by a verifier that publishes the scopes
// the MCP authorization server advertises. pier copies MCPScopesSupported
// into protected-resource metadata when the slice is non-empty, and
// otherwise keeps its own fallback list. It is a separate interface so
// MCPTokenVerifier stays MCPVerifyToken and MCPResource.
type MCPAdvertisedScopes interface {
MCPScopesSupported() []string
}
// MemberTokenScopeCatalog is the compiled member personal-token scope list,
// published by the plugin that owns member tokens. The method is not named
// FrontendTokenScopes: HasFrontendTokenScopes already has that method.
type MemberTokenScopeCatalog interface {
MemberTokenScopes() []FrontendTokenScope
}
// MCPGrant is a verified OAuth access token: the frontend user, the client,
// the token row, the granted scopes, the expiry and, for a grant carrying
// the backend scope, the backend account the user consented to.

View File

@@ -0,0 +1,30 @@
package pact
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// TestMCPCallFromBareContext pins D-11: a context that never stored an
// MCP call answers ok false, including a plain HTTP request.
func TestMCPCallFromBareContext(t *testing.T) {
if _, ok := MCPCallFrom(context.Background()); ok {
t.Fatal("MCPCallFrom(context.Background()) ok = true, want false")
}
req := httptest.NewRequest(http.MethodGet, "http://acme.test/items", nil)
if _, ok := MCPCallFrom(req.Context()); ok {
t.Fatal("MCPCallFrom on a request that never called WithMCPCall ok = true, want false")
}
}
// TestFrontendTokenScopeLiteralWithoutImplies pins that a composite
// literal written before Implies existed still compiles and leaves the
// field empty, so existing declarations stay valid.
func TestFrontendTokenScopeLiteralWithoutImplies(t *testing.T) {
scope := FrontendTokenScope{Name: "servers:read", Label: "Read servers", Description: "List servers."}
if scope.Implies != "" {
t.Fatalf("Implies = %q, want empty", scope.Implies)
}
}

View File

@@ -0,0 +1,32 @@
package pact
import (
"context"
"testing"
)
// TestMCPCallRoundTrip proves the context accessor stores and reads one
// call, and that a context without one answers ok false.
func TestMCPCallRoundTrip(t *testing.T) {
want := MCPCall{
Subject: MCPSubject{Guard: "user", UserID: 7, Scopes: []string{"read"}, ClientID: "c1", TokenID: 3},
SessionID: "sess-1",
Tool: "acme_echo",
}
got, ok := MCPCallFrom(WithMCPCall(context.Background(), want))
if !ok {
t.Fatal("MCPCallFrom ok = false, want true")
}
if got.Subject.Guard != want.Subject.Guard || got.SessionID != want.SessionID || got.Tool != want.Tool {
t.Fatalf("MCPCallFrom = %+v, want guard %q session %q tool %q", got, want.Subject.Guard, want.SessionID, want.Tool)
}
if _, ok := MCPCallFrom(context.Background()); ok {
t.Fatal("MCPCallFrom(context.Background()) ok = true, want false")
}
if _, ok := MCPCallFrom(nil); ok {
t.Fatal("MCPCallFrom(nil) ok = true, want false")
}
if WithMCPCall(nil, want) != nil {
t.Fatal("WithMCPCall(nil) invented a context")
}
}

View File

@@ -29,8 +29,13 @@ type RecordChange struct {
// for a write outside the admin forms. Model is the record's morph type
// (lagoon.MorphType: the model's MorphName, else its table name), the same
// value a content type declares in ContentDescriptor.Model. ID is the
// primary key as text. Locale is the request's content locale, "" when it
// has none. Created is true on create. Changes lists the attributes the save
// primary key as text. Locale is the locale of the request that wrote the
// record, "" when it has none; the admin reports the primary tag of the
// administrator's request locale (the first tag of Accept-Language, or the
// preferred locale). It does not say where values were written: an admin
// save writes the record's row in the default content locale and reports
// each translated value with its own RecordChange.Locale, so key per-locale
// work on the changes. Created is true on create. Changes lists the attributes the save
// changed with their old and new values; on create it lists every written
// column with a nil Old. A save that changed nothing still fires, with an
// empty Changes, so subscribers may re-check what they derive from the

View File

@@ -10,7 +10,7 @@ Opt-in MCP endpoint that lists the tools and prompts plugins declare per caller
pier mounts a streamable HTTP MCP endpoint (POST, GET and DELETE at `mcp.path`, default `/mcp`) inside the application binary, on the official Go SDK. A plugin takes part by implementing `pact.HasMCP`: each `pact.MCPTool` names one of the plugin's own routes by method and path, an input struct whose tags say which fields fill the path, the query string and the body, and the guard the tool runs as. At boot pier compiles every declaration against the route table (an unknown route, a duplicate or invalid name, a route that is not token-guarded, a non-object input, a `path:` field the schema does not require, an embedded struct on a `GET` or `DELETE` tool all fail boot naming the plugin and the tool), reflects the input schema from the struct and prefixes the exposed name with the plugin's short code (`acme.blog` declaring `create_post` exposes `blog_create_post`).
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject, but only while that link names the administrator the grant was consented for (`pact.MCPGrant.BackendUserID`): a link changed to anyone else, or a grant that records no consented administrator, gets no backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved; the inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject, but only while that link names the administrator the grant was consented for (`pact.MCPGrant.BackendUserID`): a link changed to anyone else, or a grant that records no consented administrator, gets no backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved, and, immediately before that marker, a `pact.MCPCall` stored with `pact.WithMCPCall`. `pact.MCPCall.Subject` is the subject resolved for the tool's guard, `pact.MCPCall.Tool` is the exposed tool name and `pact.MCPCall.SessionID` is the MCP session id, empty on the stateless path and never the audit word used when a stateful call has no session. A route reads the call with `pact.MCPCallFrom`; a context pier did not dispatch answers false. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The read/write ceiling is unchanged: a grant without `write` still sees only read-only tools. The inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
Sessions (`Mcp-Session-Id`) are bound to the identity that opened them (client and user, or the personal token), live in a store with a sliding idle TTL, and are served by a per-session SDK server on whichever instance receives the request, rehydrated from the stored session state when it is not local. The memory store serves one instance; the Redis store shares sessions across instances and fans subject events out to every instance, so a permission or link change pushes `notifications/tools/list_changed` to the sessions it concerns and a revocation ends them everywhere. Clients of protocol 2026-07-28 and later, which have no sessions, are served statelessly from the same path by the SDK's stateless handler over per-identity servers, sharing identity, dispatch and filtering with the stateful path.
@@ -30,7 +30,7 @@ Use cases:
- Route binding by exact method and pattern (D-10); a tool targets a route that carries its guard's token-accepting middleware or names its guard explicitly (D-15); the requirements a route's middleware implies come from the guard provider's `MCPRequirements` and are checked together with the tool's `Permissions` (D-16).
- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct (whose promoted fields would be body fields) fails boot there. A `path:` field must be required (no `omitempty` or `omitzero`), and at call time pier delivers only path values the HTTP mux could: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before dispatch. Numbers in path and query values keep their literal digits, so integer ids above 2^53 stay exact. An optional `Output` struct produces the output schema.
- Annotations from the method (D-14): `GET` read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override; the destructive and open-world hints are always explicit.
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `pact.WithMCPCall` immediately before `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
- Results (D-13): 2xx is text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error whose text is the route's body; a too-large answer is a tool error with a fixed text.
- Built-in `backend` guard provider (`pier.BackendGuard`): routes behind cabana's `backend` middleware, listing through `cabana.Allows` on the administrator's principal from `cabana.BackendPrincipal`, an admin personal token's scope limit computed per dispatched route method with `cabana.ScopeLimitFor`; a plugin that registers its own `backend` provider wins.
- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation. One `pact.MCPPersonalTokenResolver` serves the application; a subject it answers without a `TokenID` is refused, so personal-token users never share a binding.
@@ -42,7 +42,7 @@ Use cases:
- Client address: before resolving a bearer, pier puts the client address on the resolvers' context with `towel.WithClientIP`, taken from the host when it offers one (surf applies `http.trusted_proxies`) or from the request's `RemoteAddr`, so a resolver can log a failed attempt with it. The periodic bearer re-check of a GET or `subscriptions/listen` stream carries the address the stream was opened from.
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. Past capacity the least recently used idle server is evicted (and its sessions closed); a server that is serving a request, a `subscriptions/listen` stream included, is never evicted, so capacity never ends a quiet listener or a tool call in flight, and the LRU stays above capacity while every server is busy. A stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one. A guard provider error during this re-filtering is not a refusal: the cached server stays as it was and the next request retries (logged as `pier: widening deferred`); a fan-out recompute that meets one hides the failing items and leaves the server to be re-filtered by the binding's next request.
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
- Protected resource metadata (RFC 9728): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published `pact.MCPTokenVerifier` also implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes`. pier does not read `pact.MemberTokenScopeCatalog` and does not widen the read/write ceiling. A `mcp.resource` that differs from the verifier's resource is a boot warning. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A `Render` error is logged server-side and answered with the fixed JSON-RPC error `pier: the prompt could not be rendered`, so no database or provider error reaches the agent. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.
@@ -110,7 +110,7 @@ An MCP client then connects to `https://example.com/mcp` with an OAuth access to
| `pier.Record` | One stored session: binding, session state JSON and creation time. |
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id, publishing instance, and the optional token id and client id that narrow a revocation. |
| `pier.LogComponent` | `pier`, the diary component of the audit lines. |
| `pier.PRMScopes` | The `scopes_supported` list of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`). |
| `pier.PRMScopes` | The `scopes_supported` fallback of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`) when the verifier does not implement `pact.MCPAdvertisedScopes` or `pact.MCPAdvertisedScopes.MCPScopesSupported` is empty. |
Configuration keys are exported as constants: `pier.EnabledKey`, `pier.PathKey`, `pier.AllowPersonalTokensKey`, `pier.SessionStoreKey`, `pier.SessionIdleTTLKey`, `pier.SessionMaxPerBindingKey`, `pier.ResourceKey`, `pier.ThrottleKey`, `pier.AllowedOriginsKey`, `pier.CallTimeoutKey`, `pier.MaxResultBytesKey`, with defaults `pier.DefaultPath`, `pier.DefaultSessionStore`, `pier.DefaultIdleTTL`, `pier.DefaultMaxPerBinding`, `pier.DefaultThrottle`, `pier.DefaultCallTimeout` and `pier.DefaultMaxResultBytes`, and store names `pier.StoreMemory` and `pier.StoreRedis`.

View File

@@ -35,7 +35,9 @@ const tokenInvalid = "pier: the caller's token is no longer valid"
// callInfo is what a dispatched call copies from the outer MCP request: the
// client address, the Host, the forwarding, real-ip and language headers,
// plus the session the call belongs to, for the audit line.
// plus the session the call belongs to, for the audit line. stateless is
// set on the stateless path so the MCP call's session id stays empty
// instead of a synthesized SDK session id.
type callInfo struct {
remoteAddr string
host string
@@ -45,6 +47,7 @@ type callInfo struct {
realIP string
acceptLanguage string
session string
stateless bool
}
// callInfoFrom reads callInfo off an HTTP request.
@@ -205,6 +208,11 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
if credential == nil && t.guardName == BackendGuard {
credential = id.credential
}
sessionID := info.session
if sessionID == ephemeralSession {
sessionID = ""
}
callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name})
callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential})
var req *http.Request

View File

@@ -0,0 +1,103 @@
package pier_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/pier"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// TestDispatchMCPCallBothGuards pins D-11: one frontend tool call and one
// backend tool call each carry MCPCall for the guard that owns the tool.
// SessionID is the stateful session, never the word ephemeral. A
// personal-token versus OAuth difference is not asserted here.
func TestDispatchMCPCallBothGuards(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
links := newFakeLinks()
links.set(10, consentedAdmin)
front := &fakeGuard{}
back := &fakeGuard{name: pier.BackendGuard, tokenMW: pier.BackendGuard}
verifier := newFakeVerifier(map[string]pact.MCPGrant{
"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"),
})
app := newApp(t, appSpec{
guards: []pact.MCPGuard{front, back},
verifier: verifier,
links: links,
})
acme := routePlugin{
id: "test.acme",
routes: []testRoute{{
method: http.MethodGet,
path: "/acme/ping",
mw: []string{"acme.auth"},
h: mcpCallHandler,
}},
tools: []pact.MCPTool{{
Name: "ping",
Method: http.MethodGet,
Path: "/acme/ping",
Input: struct{}{},
}},
}
office := routePlugin{
id: "test.office", guard: pier.BackendGuard, middleware: pier.BackendGuard,
routes: []testRoute{{
method: http.MethodGet,
path: "/office/ping",
mw: []string{pier.BackendGuard},
h: mcpCallHandler,
}},
tools: []pact.MCPTool{{
Name: "ping",
Method: http.MethodGet,
Path: "/office/ping",
Input: struct{}{},
}},
}
in := startInstance(t, app, acme, office)
cs := sdkClient(t, ctx, in.url(), "oas_at_admin", clientOpts{})
frontBody := callMCP(t, ctx, cs, "acme_ping")
if frontBody["ok"] != true || frontBody["guard"] != "acme" || frontBody["tool"] != "acme_ping" {
t.Fatalf("frontend MCPCall = %+v, want guard acme tool acme_ping", frontBody)
}
if session, _ := frontBody["session"].(string); session == "" || session == "ephemeral" {
t.Fatalf("frontend session = %q, want the stateful session id", session)
}
backBody := callMCP(t, ctx, cs, "office_ping")
if backBody["ok"] != true || backBody["guard"] != pier.BackendGuard || backBody["tool"] != "office_ping" {
t.Fatalf("backend MCPCall = %+v, want guard %s tool office_ping", backBody, pier.BackendGuard)
}
if session, _ := backBody["session"].(string); session == "" || session == "ephemeral" {
t.Fatalf("backend session = %q, want the stateful session id", session)
}
}
func mcpCallHandler(w http.ResponseWriter, r *http.Request) {
call, ok := pact.MCPCallFrom(r.Context())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": ok,
"guard": call.Subject.Guard,
"tool": call.Tool,
"session": call.SessionID,
})
}
func callMCP(t *testing.T, ctx context.Context, cs *mcp.ClientSession, name string) map[string]any {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name})
if err != nil || res.IsError {
t.Fatalf("tools/call %s: %+v, %v", name, res, err)
}
return structured(t, res)
}

View File

@@ -0,0 +1,155 @@
package pier_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/wristband"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// fixedMCPScopes is the MCP flavour's resolved list when ScopesSupported is empty.
var fixedMCPScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// advertisedVerifier is the fake OAuth verifier plus a declared scope list.
type advertisedVerifier struct {
acmeVerifier
scopes []string
}
func (v advertisedVerifier) MCPScopesSupported() []string {
return append([]string(nil), v.scopes...)
}
// TestMCPSeam is the tracer for one advertised scope list and one MCP call
// on the dispatch context: empty MCP metadata includes backend, a non-empty
// list is used as given, pier copies a verifier's advertised scopes (and
// keeps PRMScopes otherwise), and one frontend tool call observes MCPCall
// beside the bouncer dispatch marker.
func TestMCPSeam(t *testing.T) {
opts := wristband.DefaultOptions()
opts.Issuer = "https://issuer.example.test"
opts.Resource = "https://resource.example.test/mcp"
opts.ScopesSupported = nil
empty := wristband.NewServer(opts)
if got := metadataScopes(t, empty); !slices.Equal(got, fixedMCPScopes) {
t.Fatalf("empty MCP scopes_supported = %v", got)
}
declared := append(append([]string(nil), fixedMCPScopes...), "acme:read")
opts.ScopesSupported = declared
listed := wristband.NewServer(opts)
if got := metadataScopes(t, listed); !slices.Equal(got, declared) {
t.Fatalf("declared MCP scopes_supported = %v", got)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
advertised := append(append([]string(nil), fixedMCPScopes...), "acme:read")
app := newApp(t, appSpec{
verifier: advertisedVerifier{scopes: advertised},
guards: []pact.MCPGuard{&acmeGuard{}},
})
plugin := routePlugin{
id: "test.acme",
routes: []testRoute{{
method: http.MethodPost,
path: "/acme/seam",
mw: []string{"acme.auth"},
h: seamHandler,
}},
tools: []pact.MCPTool{{
Name: "seam",
Method: http.MethodPost,
Path: "/acme/seam",
Input: struct{}{},
}},
}
in := startInstance(t, app, plugin)
if got := prmScopes(t, in.srv.URL); !slices.Equal(got, advertised) {
t.Fatalf("advertised PRM scopes = %v, want %v", got, advertised)
}
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_seam"})
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if res.IsError {
t.Fatalf("tools/call isError: %+v", res.Content)
}
body := structured(t, res)
if body["ok"] != true || body["tool"] != "acme_seam" || body["dispatched"] != true || body["guard"] != "acme" {
t.Fatalf("handler saw %+v, want MCPCall tool acme_seam and a dispatch", body)
}
if sid, _ := body["session"].(string); sid == "" || sid == "ephemeral" {
t.Fatalf("session = %q, want the stateful session id", sid)
}
plain := startInstance(t, newApp(t, appSpec{
verifier: acmeVerifier{},
guards: []pact.MCPGuard{&acmeGuard{}},
}), plugin)
if got := prmScopes(t, plain.srv.URL); !slices.Equal(got, pier.PRMScopes) {
t.Fatalf("PRM without MCPAdvertisedScopes = %v, want %v", got, pier.PRMScopes)
}
}
func metadataScopes(t *testing.T, srv *wristband.Server) []string {
t.Helper()
rec := httptest.NewRecorder()
srv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if rec.Code != http.StatusOK {
t.Fatalf("metadata status = %d", rec.Code)
}
var doc struct {
Scopes []string `json:"scopes_supported"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
return doc.Scopes
}
func prmScopes(t *testing.T, base string) []string {
t.Helper()
resp, err := http.Get(base + "/.well-known/oauth-protected-resource")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("PRM status = %d body %s", resp.StatusCode, raw)
}
var doc struct {
Scopes []string `json:"scopes_supported"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
return doc.Scopes
}
func seamHandler(w http.ResponseWriter, r *http.Request) {
call, ok := pact.MCPCallFrom(r.Context())
_, dispatched := bouncer.Dispatched(r.Context(), "acme")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": ok,
"tool": call.Tool,
"session": call.SessionID,
"guard": call.Subject.Guard,
"dispatched": dispatched,
})
}

View File

@@ -10,7 +10,8 @@ import (
)
// PRMScopes is the scopes_supported list of the protected resource
// metadata: the OAuth server's MCP scopes plus backend (D-21).
// metadata when the published verifier does not advertise a non-empty
// list: the fixed MCP scopes, including backend.
var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// mountPRM serves the RFC 9728 protected resource metadata at
@@ -22,6 +23,7 @@ var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// resource is logged as a warning at boot (Pitfall 8).
func (p *Pier) mountPRM() error {
issuer := p.cfg.AppURL
scopes := append([]string(nil), PRMScopes...)
if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil {
if res := verifier.MCPResource(); res != "" {
if u, err := url.Parse(res); err == nil && u.Scheme != "" && u.Host != "" {
@@ -32,10 +34,15 @@ func (p *Pier) mountPRM() error {
"mcp_resource", p.cfg.Resource, "verifier_resource", res)
}
}
if advertised, ok := verifier.(pact.MCPAdvertisedScopes); ok {
if list := advertised.MCPScopesSupported(); len(list) > 0 {
scopes = append([]string(nil), list...)
}
}
}
meta := &oauthex.ProtectedResourceMetadata{
Resource: p.cfg.Resource,
ScopesSupported: append([]string(nil), PRMScopes...),
ScopesSupported: scopes,
BearerMethodsSupported: []string{"header"},
}
if issuer != "" {

View File

@@ -0,0 +1,58 @@
package pier_test
import (
"net/http"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/pier"
)
// TestPRMUsesAdvertisedScopes pins D-04: a verifier that implements
// MCPAdvertisedScopes supplies the protected-resource scopes, including a
// declared name.
func TestPRMUsesAdvertisedScopes(t *testing.T) {
declared := []string{"read", "write", "ai", "offline_access", "backend", "acme:read"}
app := newApp(t, appSpec{
verifier: advertisedVerifier{scopes: declared},
guards: []pact.MCPGuard{&acmeGuard{}},
})
in := startInstance(t, app, prmPlugin())
got := prmScopes(t, in.srv.URL)
if !slices.Equal(got, declared) || !slices.Contains(got, "acme:read") {
t.Fatalf("PRM scopes = %v, want %v", got, declared)
}
}
// TestPRMFallsBackToPRMScopes pins D-04: a verifier that does not implement
// MCPAdvertisedScopes leaves the fixed list, which includes backend.
func TestPRMFallsBackToPRMScopes(t *testing.T) {
app := newApp(t, appSpec{
verifier: acmeVerifier{},
guards: []pact.MCPGuard{&acmeGuard{}},
})
in := startInstance(t, app, prmPlugin())
got := prmScopes(t, in.srv.URL)
if !slices.Equal(got, pier.PRMScopes) || !slices.Contains(got, "backend") {
t.Fatalf("PRM scopes = %v, want %v", got, pier.PRMScopes)
}
}
func prmPlugin() routePlugin {
return routePlugin{
id: "test.acme",
routes: []testRoute{{
method: http.MethodGet,
path: "/acme/items",
mw: []string{"acme.auth"},
h: func(http.ResponseWriter, *http.Request) {},
}},
tools: []pact.MCPTool{{
Name: "show",
Method: http.MethodGet,
Path: "/acme/items",
Input: struct{}{},
}},
}
}

View File

@@ -704,7 +704,7 @@ func containsPrompt(prompts []*prompt, name string) bool {
func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, info := identityFromExtra(req.Extra, session)
if req.Session != nil && req.Session.ID() != "" {
if !info.stateless && req.Session != nil && req.Session.ID() != "" {
info.session = req.Session.ID()
}
return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil

View File

@@ -12,6 +12,7 @@ import (
"time"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -102,6 +103,13 @@ func (sp *statelessPath) ServeHTTP(w http.ResponseWriter, r *http.Request) {
use := &entryUse{}
r = r.WithContext(context.WithValue(r.Context(), entryUseKey{}, use))
defer sp.release(use)
// The stateless path has no MCP session. Mark the call before the
// handler runs so a synthesized SDK session id is not copied into it.
if ti := auth.TokenInfoFromContext(r.Context()); ti != nil && ti.Extra != nil {
info, _ := ti.Extra[extraCall].(callInfo)
info.stateless = true
ti.Extra[extraCall] = info
}
if r.Method != http.MethodPost || r.Body == nil {
sp.handler.ServeHTTP(w, r)
return

View File

@@ -27,8 +27,8 @@ Use cases:
- RFC 8414 metadata document (`wristband.Server.Metadata`) advertising the authorize, token and registration endpoints under the issuer, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter.
- RFC 7591 dynamic client registration (`wristband.Server.Register`): JSON only, a bounded request body, redirect URI validation (HTTPS, or loopback HTTP), public (`none`) and confidential (`client_secret_post`, `client_secret_basic`) clients, a cap on unrevoked clients and a sweep of old clients that never got consent, all in one transaction.
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are exactly `wristband.Options.ScopesSupported`, the values the metadata document advertises (`read`, `write`, `ai` and `offline_access` by default; an empty list keeps that default), so a host that lists an extra scope accepts it at authorize and any other value is `invalid_scope`.
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are the same resolved list `wristband.Server.Metadata` advertises. A non-empty `wristband.Options.ScopesSupported` is used as given (`read`, `write`, `ai` and `offline_access` from `wristband.DefaultOptions`, which still refuses `backend`; a host that lists an extra scope accepts it). An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. Any other value is `invalid_scope`. OpenID Connect discovery stays `openid`, `profile` and `email` and does not use this list. The OAuth server plugin publishes that list as `pact.MCPAdvertisedScopes`; pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty. A route the MCP endpoint runs reads the caller with `pact.MCPCallFrom`.
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show, including `wristband.PendingRequestView.OfflineAccess`, the pending row's flag; `offline_access` is not a scope on the view), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`; the signature is unchanged) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
- Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call.
- Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it.
- OpenID Connect provider flavour: `wristband.NewOIDCServer` builds a second `wristband.Server` on the same engine with discovery (`wristband.Server.Discovery`), a JWKS endpoint (`wristband.Server.JWKS`), authorization with a resumable request and consent (`wristband.Server.Authorize`, `wristband.Server.Resume`), a token endpoint that adds an `id_token` signed by the active key, and userinfo (`wristband.Server.UserInfo`). Storage arrives through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and pages and the signed-in user through `wristband.OIDCHost`. The MCP flavour and its interfaces are unchanged.
@@ -128,7 +128,7 @@ func ProviderRoutes(r pact.Router, srv *wristband.Server) {
| `wristband.Server.Register` | Handler for `POST /oauth/mcp/register` (RFC 7591). |
| `wristband.Server.Authorize` | Handler for `GET /oauth/mcp/authorize`; on an OIDC server `GET /oauth/authorize` (start) and `POST /oauth/authorize` (consent decision). |
| `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants); on an OIDC server `POST /oauth/token` with an `id_token`. |
| `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. |
| `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. `wristband.PendingRequestView.OfflineAccess` is the pending row's flag. |
| `wristband.Server.IssueCode` | Grants consent and returns the redirect URL carrying the code. |
| `wristband.Server.DenyPending` | Refuses consent and returns the `access_denied` redirect URL. |
| `wristband.Server.Revoke` | Revokes an access token and its refresh-token lineage. |
@@ -187,7 +187,7 @@ wristband reads no config keys or environment variables; the application passes
| Field | Default |
|-------|---------|
| `wristband.Options.ServiceDocumentationPath` | `/help` |
| `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` (also the authorize allow-list; empty keeps this default) |
| `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` on `wristband.DefaultOptions`, used as given, so that list still refuses `backend`. An empty list on the MCP flavour resolves to those four plus `backend`. A non-empty list is not widened. |
| `wristband.Options.TokenEndpointAuthMethodsSupported` | `none`, `client_secret_post`, `client_secret_basic` |
| `wristband.Options.AuthorizationResponseIssParameterSupported` | `true` |
| `wristband.Options.DCRClientCap` | `200` |

View File

@@ -18,22 +18,17 @@ import (
"strings"
)
// authorizeAllowedScopes mirrors PHP OAuthAuthorizeController::ALLOWED_SCOPES:
// the allow-list of a server whose Options.ScopesSupported is empty.
var authorizeAllowedScopes = map[string]bool{
"read": true, "write": true, "ai": true, "offline_access": true,
}
// allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this
// server: exactly Options.ScopesSupported, the values the metadata document
// advertises, so a host that lists an extra scope (such as backend for the
// MCP module) accepts it at authorize; an empty list keeps the PHP default.
// server: the same resolvedScopes slice Metadata advertises. A non-empty
// Options.ScopesSupported is used as given, so a host that lists an extra
// scope accepts it and a list without backend still refuses it. An empty
// list on the MCP flavour resolves to the five fixed names, backend
// included. Scope resolution runs after the client, redirect and PKCE
// checks in Authorize.
func (s *Server) allowedAuthorizeScopes() map[string]bool {
if s == nil || len(s.opts.ScopesSupported) == 0 {
return authorizeAllowedScopes
}
allowed := make(map[string]bool, len(s.opts.ScopesSupported))
for _, sc := range s.opts.ScopesSupported {
scopes := s.resolvedScopes()
allowed := make(map[string]bool, len(scopes))
for _, sc := range scopes {
if sc = strings.TrimSpace(sc); sc != "" {
allowed[sc] = true
}

View File

@@ -40,6 +40,9 @@ type PendingRequestView struct {
RedirectHost string
ScopesRequested []string // the pending row's own (already ceiling-truncated) scopes, unfiltered by mintability
ExpiresAt time.Time
// OfflineAccess is the pending row's flag. offline_access is peeled out
// of Scopes at authorize and is not a scope on this view.
OfflineAccess bool
}
// PendingRequest resolves requestID for GET .../oauth/request/{request_id}
@@ -58,6 +61,7 @@ func (s *Server) PendingRequest(ctx context.Context, requestID string, userID ui
RedirectHost: host,
ScopesRequested: pending.Scopes,
ExpiresAt: pending.ExpiresAt,
OfflineAccess: pending.OfflineAccess,
}, nil
}

View File

@@ -8,10 +8,11 @@ import (
"testing"
)
// TestMCPScopesSmoke covers the configurable authorize scope set of phase
// 19.7 (RESEARCH Pitfall 5, D-21 framework side): the allow-list is exactly
// Options.ScopesSupported, so a host that lists backend accepts it, while
// the default server keeps refusing it.
// TestMCPScopesSmoke covers the configurable authorize scope set: a non-empty
// Options.ScopesSupported is the allow-list and the metadata list, so a host
// that lists backend accepts it, while DefaultOptions (the four PHP names,
// which do not include backend) still refuses it. An empty list on the MCP
// flavour resolves to read, write, ai, offline_access and backend.
func TestMCPScopesSmoke(t *testing.T) {
backend := newMemoryBackend()
opts := DefaultOptions()
@@ -69,10 +70,25 @@ func TestMCPScopesSmoke(t *testing.T) {
t.Fatalf("default server accepted backend: %v", q)
}
// An empty ScopesSupported keeps the PHP default allow-list.
// An empty ScopesSupported resolves to the five fixed names and accepts backend.
emptyBackend := newMemoryBackend()
empty := DefaultOptions()
empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil
if got := NewServer(empty).allowedAuthorizeScopes(); !got["read"] || !got["offline_access"] || got["backend"] || len(got) != 4 {
emptySrv := NewServer(empty)
emptySrv.SetBackend(emptyBackend)
insertAuthorizeTestClient(emptyBackend, "cli-empty", []string{authorizeTestRedirect}, nil)
if got := emptySrv.allowedAuthorizeScopes(); !got["read"] || !got["write"] || !got["ai"] || !got["offline_access"] || !got["backend"] || len(got) != 5 {
t.Fatalf("allowed scopes with empty ScopesSupported = %v", got)
}
if q := authorize(emptySrv, "cli-empty"); q["error"] != "" {
t.Fatalf("empty ScopesSupported refused backend: %v", q)
}
rec = httptest.NewRecorder()
emptySrv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if !slices.Equal(doc.Scopes, []string{"read", "write", "ai", "offline_access", "backend"}) {
t.Fatalf("empty scopes_supported = %v", doc.Scopes)
}
}

View File

@@ -34,7 +34,9 @@ type Options struct {
ServiceDocumentationPath string
// ScopesSupported is the RFC 8414 scopes_supported list. PHP default:
// ["read","write","ai","offline_access"].
// ["read","write","ai","offline_access"]. On the MCP flavour an empty
// or nil list resolves to those four plus backend; a non-empty list is
// used as given. OIDC discovery does not read this field.
ScopesSupported []string
// TokenEndpointAuthMethodsSupported is the RFC 8414
@@ -189,13 +191,32 @@ func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) {
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
CodeChallengeMethodsSupported: []string{"S256"},
TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported,
ScopesSupported: s.opts.ScopesSupported,
ScopesSupported: s.resolvedScopes(),
ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath,
AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported,
}
writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"})
}
// mcpEmptyScopes is the MCP flavour's scope list when Options.ScopesSupported
// is empty or nil. A non-empty list is not widened to include backend.
var mcpEmptyScopes = []string{"read", "write", "ai", "offline_access", "backend"}
// resolvedScopes is the one scope slice Metadata advertises and
// allowedAuthorizeScopes accepts. A non-empty Options.ScopesSupported is
// cloned as given. An empty or nil list on the MCP flavour (NewServer, the
// flavour zero value) resolves to mcpEmptyScopes. The OIDC flavour is not
// given that list: its discovery document is separate.
func (s *Server) resolvedScopes() []string {
if s != nil && len(s.opts.ScopesSupported) > 0 {
return append([]string(nil), s.opts.ScopesSupported...)
}
if s != nil && s.flavour == flavourOIDC {
return append([]string(nil), s.opts.ScopesSupported...)
}
return append([]string(nil), mcpEmptyScopes...)
}
// writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document
// (matching the wire/response.go WriteJSON technique) but never falls back to
// the house opaque-500 envelope: raw RFC responses must never acquire a

View File

@@ -994,9 +994,11 @@ consumer_run() {
return "$rc"
}
# last_tag prints the newest release tag of this checkout.
# last_tag prints the newest release tag that does not contain HEAD: the
# previous release, also once HEAD itself carries the new release tag (a
# post-release run must not compare the release with itself).
last_tag() {
git -C "$ROOT" tag --sort=-v:refname | head -n 1
git -C "$ROOT" tag --sort=-v:refname --no-contains HEAD | head -n 1
}
# base_worktree creates the last tag's worktree once (BASE_TREE).