Compare commits

...

276 Commits

Author SHA1 Message Date
Jakub Zych
a917966d31 docs(quick-261106-kt2): in-process deferred:purge, migrate and queue:clear reuse the published database 2026-11-06 15:38:37 +01:00
Jakub Zych
99abe88b6a fix(conga): queue:clear reuses the app's published database (quick-261106-kt2)
Run in-process on an app that already published its pool, queue:clear failed
with "backpack: duplicate provider for *sql.DB". It now goes through
withAppDB; the CLI path is unchanged.
2026-11-06 15:28:08 +01:00
Jakub Zych
16a20088a3 fix(lagoon): run deferred:purge and the migrate commands on the app's published database (quick-261106-kt2)
The nightly framework entry deferred:purge failed under serve's worker and
schedule:run with "backpack: duplicate provider for *sql.DB": withDB always
opened and published a second pool. It now reuses the published *gorm.DB and
leaves that pool open; the CLI path (nothing published) is unchanged.
2026-11-06 15:23:28 +01:00
Jakub Zych
8ebfedf64c docs(quick-261106-kt1): MCP 401 challenge names only resource_metadata, T-19.7-10 updated 2026-11-06 15:15:03 +01:00
Jakub Zych
609caa4f3f fix(pier): MCP 401 challenge names only resource_metadata
- drop the response-writer wrapper that appended scope="read" to every 401
- the SDK handler now gets the original writer; RequireBearerToken still sets no Scopes
- TestPierTracerSmoke pins the exact challenge for a bad and a missing bearer
- README and docs/services/mcp.md describe the header without a scope parameter
2026-11-06 15:09:10 +01:00
Jakub Zych
ef47baf0a1 docs(quick-261106-jhk): MCP authorize drops OIDC identity scopes for the ChatGPT connector 2026-11-06 14:35:14 +01:00
Jakub Zych
aba678b196 fix(wristband): drop OIDC identity scopes at MCP authorize
Clients that also read the issuer's OpenID Connect discovery document
add openid, profile or email to their MCP authorize request, and the
MCP flavour refused those requests with invalid_scope ("scope contains
an unsupported value"), so they could not connect.

The three identity scopes are now dropped before the allow-list check,
as RFC 6749 section 3.3 lets a server grant fewer scopes than requested.
A request left with no other scope gets the read default. Any other
unknown scope, including a differently cased identity name, is still
refused. The MCP flavour still never grants identity scopes or issues
an id_token, and the metadata document is unchanged.
2026-11-06 14:17:43 +01:00
Jakub Zych
5db032f52f docs(35): UI design contract 2026-11-06 11:56:48 +01:00
Jakub Zych
928ae0097d docs(35): capture phase context 2026-11-06 11:02:13 +01:00
Jakub Zych
c755209964 docs: add phase 35 - mail templates and layouts editor 2026-11-06 10:18:59 +01:00
Jakub Zych
31acec420a docs(quick-261106-e5v): purge token rename leftovers, todo closed 2026-11-06 10:12:29 +01:00
Jakub Zych
d9d57c7411 docs(quick-261106-dv0): shared plugin boot logs at Debug, todo closed 2026-11-06 10:02:16 +01:00
Jakub Zych
8a600ca704 docs(quick-261106-d20): fonoteka.go uploads named after the sniffed type, attach.UploadWriterOptions exported, 2 todos closed 2026-11-06 09:38:19 +01:00
Jakub Zych
903859a131 feat(lagoon/attach): export UploadWriterOptions for blob writers that bypass Store (quick-261106-d20)
- attach.UploadWriterOptions (was the unexported uploadWriterOptions) gives
  code that writes an upload to the bucket itself the same Content-Type and
  Content-Disposition: attachment for non-inline types that attach.Store stores
- writeBlob calls it; Store behaviour unchanged
- TestUploadWriterOptions table test
- lagoon README, docs/database/attachments.md and docs/services/storage.md
  describe it for writers that bypass attach.Store
2026-11-06 09:31:51 +01:00
Jakub Zych
9fa740e8b7 docs: capture todo - blob writers that bypass attach.Store set Content-Disposition 2026-11-06 09:18:55 +01:00
Jakub Zych
895248eef2 docs(quick-batch-261105-oc4): security hardening batch, 5 items, 12 todos closed 2026-11-05 18:46:35 +01:00
Jakub Zych
27c98bf22a fix(lagoon/attach): StaticHandler sends the stored Content-Disposition and nosniff (quick-261105-oc8)
- attach.StaticHandler and attach.StaticHandlerPublic set X-Content-Type-Options: nosniff on every response
- the stored disposition is read with one Attributes call after the is_public gate and sent on GET and HEAD; a value mime.ParseMediaType rejects is sent as attachment
- blobs without a stored disposition are served as before
- lagoon README, storage and attachments docs pages, surf uploads comment updated
2026-11-05 18:13:27 +01:00
Jakub Zych
5aa846c5e8 docs(quick-261105-eab): OIDC grant flows serialized with revoke 2026-11-05 10:42:26 +01:00
Jakub Zych
f9255272c0 docs(phase-20): add security threat verification 2026-11-05 10:17:05 +01:00
Jakub Zych
1b1a553fb6 docs(phase-19.4.1): add security threat verification 2026-11-05 10:12:53 +01:00
Jakub Zych
95cced94fc docs(phase-11.2): add security threat verification 2026-11-05 10:09:29 +01:00
Jakub Zych
71dc0485ee docs(quick-261105-din): fix 32.1 CR-01, consent allow serialized with revoke 2026-11-05 10:00:02 +01:00
Jakub Zych
8c6b3171a8 docs(phase-19.3): add security threat verification 2026-11-05 09:33:44 +01:00
Jakub Zych
7490a310ce docs(phase-34): add security threat verification 2026-11-05 09:32:29 +01:00
Jakub Zych
439a994aac docs(phase-32.1): add security threat verification 2026-11-05 09:30:38 +01:00
Jakub Zych
7b8d205613 docs: add backlog item 999.2, SEO follow-ups after Phase 33 (v0.6.2) 2026-11-05 02:33:31 +01:00
Jakub Zych
1846e98c0f docs(33): phase 33 re-verification after the release (human_needed)
Covers 33-14-SUMMARY.md, the release and deploy logs and the release notes. Release items settled: tags, changelog posts, grzybyfunkcjonalne.pl sitemaps and post head, G1 on the demo; W5 SQL check waived by Jakub. Open: live MCP session, Rich Results Test, visual and admin checks (with grzyby checklist items 3, 5, 6, 7). Seeds 33-UAT.md.
2026-11-05 02:32:51 +01:00
Jakub Zych
7049dffd9a docs(phase-33): tick 33-14 in the roadmap 2026-11-05 02:26:02 +01:00
Jakub Zych
94c47268b5 docs(33-14): complete the tests, gate and release plan 2026-11-05 02:25:43 +01:00
Jakub Zych
6875627fdf docs(33-14): grzybyfunkcjonalne.pl step 12, smoke ok and the 404 log cleared 2026-11-05 02:24:10 +01:00
Jakub Zych
201fd0fbf9 docs(33-14): release log, post-release verification 2026-11-05 02:20:44 +01:00
Jakub Zych
211687f439 docs(33-14): release log, deploys of summercms.io, the demo, bm and grzybyfunkcjonalne.pl 2026-11-05 02:15:17 +01:00
Jakub Zych
ef164dda8e docs(33-14): release log, app pins and pushes 2026-11-05 02:01:35 +01:00
Jakub Zych
143b4dbdca docs(33-14): changelog posts and the layer patch releases 2026-11-05 01:36:02 +01:00
Jakub Zych
8edc869963 docs(33-14): release log, tags pushed and the layer install check failure 2026-11-05 01:25:43 +01:00
Jakub Zych
6d14f71ae5 style: gofmt 2026-11-05 01:00:36 +01:00
Jakub Zych
9c11b7064d docs(33): phase 33 re-verification after gap closure (human_needed) 2026-11-05 00:55:53 +01:00
Jakub Zych
6e99b1886a docs(quick-261104-xae): phase 33 gap-fix follow-ups and the gap-fix re-review 2026-11-05 00:32:56 +01:00
Jakub Zych
9d299a4455 docs(33): release notes for the gap-fix follow-ups (quick-261104-xae) 2026-11-05 00:23:39 +01:00
Jakub Zych
5ce50bedfb docs(phase-33): update tracking after 33-15 2026-11-04 23:42:46 +01:00
Jakub Zych
9637100ffa docs(33-15): complete the Go plugins gap-closure plan 2026-11-04 23:42:41 +01:00
Jakub Zych
fec697bb15 docs(33-15): release notes, release log and deferred items for the gap fixes 2026-11-04 23:30:59 +01:00
Jakub Zych
1f968994e1 docs(lagoon): attach/webp also decodes WebP
Importing attach/webp registers the encoder library's WebP decoder ahead
of the pure Go one for the whole process, so WebP uploads are decoded
through the system libwebp when it is installed. The lagoon README and
the attachments page say so and name -tags nodynamic.
2026-11-04 23:29:29 +01:00
Jakub Zych
0e951270aa docs(phase-33): update tracking after 33-16 2026-11-04 22:58:55 +01:00
Jakub Zych
a0ac2a1ee7 docs(33-16): complete the Nuxt layers gap-closure plan 2026-11-04 22:58:07 +01:00
Jakub Zych
c2e56ef339 docs(33-16): release-note drafts for the layer fixes
- vue-seo-layer v0.2.0: the route report's noindex paths, sitemap pages per
  locale and not over a catch-all, root files 503 while the backend is unknown
- vue-journal-layer v0.8.0: path-form listing routes, the catch-all upgrade
  note, post article meta
- vue-menu-layer and vue-faq-layer v0.5.0: the catch-all upgrade note
2026-11-04 22:55:40 +01:00
Jakub Zych
8d562004cb docs(33): gap-closure plans 33-15 (Go plugins) and 33-16 (Nuxt layers) 2026-11-04 22:37:13 +01:00
Jakub Zych
97abc77b14 docs(33): phase 33 verification before release (gaps_found) 2026-11-04 21:28:57 +01:00
Jakub Zych
133c3cfd1c docs(33): pre-release code review of phase 33 (four lanes) 2026-11-04 21:26:39 +01:00
Jakub Zych
c7a485177e docs(33-14): demo locale patches, release-note drafts and open items for the release checkpoint
- 33-14-vue-demo-app-locale.patch and 33-14-sm-demo-app-locale.patch move the
  demo to prefix_except_default on top of the 33-12 demo patches
- release-note drafts for the framework, the three plugins and four layers
- the read-only RISK-MAX query for production databases
- deferred items and the broken-windows ledger after the six fixes
2026-11-04 20:25:55 +01:00
Jakub Zych
3d72923617 docs(33-14): validation map 2026-11-04 20:25:45 +01:00
Jakub Zych
fbe9816a50 test: phase 33 gate, layer, consumer and admin stages
- layers: node --test runs checked with a TAP detector (self-tested), every
  vue-seo-layer smoke, guard:check and purge:check, all five journal
  expectation files (postTitleTag h1 and p), the journal purge checks and
  check-git-install.sh --self-test
- consumers: the 33-12 and 33-13 app patches pass git apply --check, and
  the 33-14 demo locale patches apply on top of the 33-12 ones in scratch
  worktrees
2026-11-04 20:25:36 +01:00
Jakub Zych
eb8a0280ca test: unit tests for content facts, field type request options, variant keys and WebP
- pact: the content kinds and tools, zero facts, the value types
- cabana: mergeRequestOptions precedence, one call per type, fail-open,
  no shared maps
- attach: VariantKey on thumbnail names
- attach/webp: every image kind, the stored content type, the
  broken-image thumbnail and a refused mode
2026-11-04 19:54:54 +01:00
Jakub Zych
513793a13d test: phase 33 gate, Go stages
- go: the framework's new unit tests by required name
- plugins: the sitemap, SEO and Journal unit tests by required name
- docker: the phase's Postgres suites by required name per plugin,
  TestChromeRenderer when a Chrome or Chromium binary is present, and the
  framework's lagoon/attach packages
2026-11-04 19:54:54 +01:00
Jakub Zych
96750982f2 docs(phase-33): update tracking after 33-13 2026-11-04 19:14:33 +01:00
Jakub Zych
5905e2b2e8 docs(33-13): complete the grzybyfunkcjonalne.pl plan
- deploy log: after-tags, production in 33-14 (no separate uat since the
  2026-10-31 cutover), the en and Additional addresses steps confirmed at
  the 33-14 release checkpoint, the production check list
- summary with the rehearsal, head diff and self-check
- deferred items: the demo moves to prefix_except_default (decided)
2026-11-04 19:14:02 +01:00
Jakub Zych
7199b26bb7 docs(33-13): grzyby patches for the release
- sm-grzybyfunkcjonalne-app: Journal tag pages off and author pages at
  /autor/:author, the sitemap and SEO config, nginx without the root-file
  locations (P33-49), check-deploy, smoke.sh (companions, route report,
  page names, seo-pages.csv, Additional addresses, rebuild), smoke-seo.sh,
  urls.txt, pl as the only enabled locale, seo-settings.yaml with the
  author templates and page suffix, DEPLOY.md release steps, tests
- vue-grzybyfunkcjonalne-app: nuxt.config on the SEO layer options
  without @nuxtjs/seo keys, no page useSeoMeta, footer sitemap link,
  title template for pages the CMS does not answer, SEO fixtures from
  the rehearsal
- local wiring note and the deferred findings
2026-11-04 18:17:26 +01:00
Jakub Zych
c59b3e61ed docs(phase-33): update tracking after 33-12 2026-11-04 18:13:05 +01:00
Jakub Zych
81c81c0c7d docs(33-12): complete the demo, summercms.io and bm plan
SUMMARY with the smoke output, the 33-14 hand-off (pins, linking, rome
steps) and the demo's /pl/ finding; deferred items and two WINDOWS
entries.
2026-11-04 18:12:25 +01:00
Jakub Zych
298c81d701 docs(33-12): demo patch tags a post in TestSEOOutput
demo:seed tags no post, so the tag_pages check in TestSEOOutput passed
without config/golem15.journal.yaml as well. The test now tags the seeded
post and requires /blog/tag/releases to resolve not_found; without the
config it fails.
2026-11-04 18:05:15 +01:00
Jakub Zych
e1d5110b29 docs(33-12): app patches for the release
- sm-demo-app: Journal, sitemap and SEO config, nginx without sitemap
  locations, env templates, DEPLOY.md SEO steps, seo-pages.csv,
  TestSEOOutput, scripts/smoke-seo.sh, release smoke on the new format
- vue-demo-app: seo.noindexRoutes for the decks, page meta for auth and
  signed-in pages, SeoSitemapLink in the footer, @nuxtjs/seo keys removed
- vue-summercmsio-app: changelog on seo.cms false, expectations for it
- sm-summercmsio-app: no change beyond the pin bumps
- 33-12-local-wiring.md: the scratch wiring for 33-13 and 33-14
2026-11-04 18:02:08 +01:00
Jakub Zych
617e15dcf0 docs(phase-33): update tracking after 33-11 2026-11-04 17:23:25 +01:00
Jakub Zych
0528e6661a docs(33-11): complete auto-links, internal links and WebP variants plan 2026-11-04 17:22:20 +01:00
Jakub Zych
5c0722429b feat(lagoon): WebP variants of attachment thumbnails
- attach.VariantKey: a thumbnail's copy in another format next to it
- attach/webp (separate package, so only binaries that import it link
  the encoder): Encode at quality 80 and Variant, which writes the WebP
  copy of a thumbnail once and leaves no file on a failed encode
- github.com/gen2brain/webp v0.6.4, decided in
  .planning/notes/2026-10-28-webp-gen2brain.md
- lagoon README and docs/database/attachments.md
2026-11-04 16:52:59 +01:00
Jakub Zych
1bb2ecf9cc docs(33-11): WebP decision note and STACK row
github.com/gen2brain/webp pinned at v0.6.4 (approved 2026-11-04), with
how it runs (purego or the wasm2go-transpiled libwebp), the linking
consequence, the benchmark and where the code lives.
2026-11-04 16:45:01 +01:00
Jakub Zych
3bfdc8616e docs(phase-33): update tracking after 33-10 2026-11-04 16:10:14 +01:00
Jakub Zych
d434b8ed19 docs(33-10): complete site audit crawler plan 2026-11-04 16:10:14 +01:00
Jakub Zych
835bf3f74c docs(33-10): resolver rule order in the chromedp note 2026-11-04 15:58:44 +01:00
Jakub Zych
e9504b1ece docs(33-10): chromedp decision note and STACK row 2026-11-04 15:43:58 +01:00
Jakub Zych
e245b54ad9 docs(phase-33): update tracking after 33-06 2026-11-04 14:44:16 +01:00
Jakub Zych
00c8fac080 docs(33-06): complete structured data, breadcrumbs, hreflang and pagination plan
- Task 4 answers: chromedp v0.21.0 and gen2brain/webp v0.6.4 approved
- the tracer resolve answer for the layer fixtures
- deferred: a repeater cannot carry a trigger (local_hours)
2026-11-04 14:44:16 +01:00
Jakub Zych
bd36b18729 docs(phase-33): update tracking after 33-09 2026-11-04 14:22:51 +01:00
Jakub Zych
80d03e741a docs(33-09): complete journal, menu and FAQ layers on the SEO layer plan 2026-11-04 14:22:04 +01:00
Jakub Zych
122aa0786e docs(phase-33): update tracking after 33-05 2026-11-04 13:55:11 +01:00
Jakub Zych
eba9567614 docs(33-05): complete SEO sitemap filter, page registry and site endpoints plan 2026-11-04 13:54:09 +01:00
Jakub Zych
1cc47f6d83 docs(phase-33): update tracking after 33-04 2026-11-04 12:45:25 +01:00
Jakub Zych
35d27d2e03 docs(33-04): complete sitemap extras plan
- 33-04-SUMMARY.md: image and News sitemaps, IndexNow, HTML sitemap API,
  management API and MCP tools in sm-sitemap-plugin
- deferred-items.md: taglist options cannot come from records
- WINDOWS.md: #29 fixed, #31 the News sources taglist deviation
2026-11-04 12:44:35 +01:00
Jakub Zych
01a316a96c docs(33-04): update the IndexNow coverage matrix 2026-11-04 12:27:13 +01:00
Jakub Zych
7bf3399a69 docs(phase-33): update tracking after 33-08 2026-11-04 12:07:28 +01:00
Jakub Zych
d581da5c92 docs(33-08): complete vue-seo-layer build guard, noindex routes and components plan 2026-11-04 12:06:43 +01:00
Jakub Zych
3ce8a36742 docs(quick-261104-fx1): min/max length on text fields and pl validation messages 2026-11-04 11:48:41 +01:00
Jakub Zych
145251481b fix(phrasebook): translate the pl string message and the numeric min/max lines (quick-261104-fx1)
- pl validation.yaml string line is Polish (Winter's pl file leaves it
  in English); the header notes the deliberate divergence
- pl validate.yaml min and max use Winter's numeric wording, since those
  keys only serve numeric and integer bounds
2026-11-04 11:42:48 +01:00
Jakub Zych
335f8e608e feat(lagoon): check min and max as length on every non-numeric field (quick-261104-fx1)
- min and max compare the characters of a string or a number's decimal
  form and the element count of a slice or map on any field without
  numeric or integer (Laravel's getSize), with the min.array and
  max.array messages for lists
- blank values, booleans, dates and byte slices are not measured; a
  malformed bound is an error on every non-numeric field
- reverse the 261104-f4x pin, prove it through the cabana settings save
  sequence, and update the README, docs and the storePassword comment
2026-11-04 11:42:15 +01:00
Jakub Zych
0785336a50 docs(phase-33): update tracking after 33-03 2026-11-04 11:29:49 +01:00
Jakub Zych
2c67f46aee docs(33-03): complete Golem15.Sitemap rewrite plan 2026-11-04 11:29:49 +01:00
Jakub Zych
7e6c42638c docs(quick-261104-f4x): lagoon.Validate string and url rules 2026-11-04 11:14:28 +01:00
Jakub Zych
d166f1c308 feat(lagoon): check min and max as character length on string and url fields (quick-261104-f4x)
- on a string or url field that is not numeric or integer, min and max
  compare utf8.RuneCountInString (Laravel's mb_strlen) and answer with
  the lagoon::validation min.string / max.string messages
- a min or max argument that is not a whole number is the
  unrecognized-rule error, whatever the value
- rule strings without string or url keep their results (pinned by a
  test); between is unchanged
2026-11-04 11:13:09 +01:00
Jakub Zych
d2ce3a2c3b feat(lagoon): accept string and url rules in lagoon.Validate (quick-261104-f4x)
- string requires a string value (pointers and named string types
  included), url a URL matching Laravel's pattern (urlPattern, shared
  with lagoon.ValidateRequest); both skip an empty or blank value
- lagoon::validate.string and .url in the en and pl catalogs, English
  fallbacks for a nil translator
- settings saves whose model rules use nullable|string|max:255 or
  nullable|url|max:500 no longer fail with an unrecognized-rule error
2026-11-04 11:08:06 +01:00
Jakub Zych
617eaef78e docs: capture todo - Enforce min/max length on text fields in lagoon.Validate 2026-11-04 11:05:55 +01:00
Jakub Zych
1e38cb2da2 docs(phase-33): update tracking after 33-02 2026-11-04 10:41:26 +01:00
Jakub Zych
dd6890e905 docs(33-02): complete Journal headings, editor and content facts plan 2026-11-04 10:40:50 +01:00
Jakub Zych
770aa1e216 docs(phase-33): update tracking after 33-07 2026-11-04 09:36:34 +01:00
Jakub Zych
94f3699cc4 docs(33-07): complete vue-seo-layer runtime plan
- SUMMARY: Task 1 answer (vue-seo-layer), Jakub's extend-in-place ruling,
  v0.1.1 compatibility evidence, deviations; next layer tag v0.2.0
- deferred-items: path canonicals under swr (pre-existing in v0.1.1)
2026-11-04 09:35:51 +01:00
Jakub Zych
b6180a7dac docs(phase-33): update tracking after 33-01 2026-11-04 08:51:10 +01:00
Jakub Zych
03fc292c8b docs(33-01): complete framework seams, phase gate and release ledger plan 2026-11-04 08:50:35 +01:00
Jakub Zych
21aa4e575f docs(33-01): release log for v0.6.1
- planned contents of framework v0.6.1: the two 33-01 seams and the
  gate script; the six Phase 32 fixes listed as already released in
  v0.5.2
- newest plugin and layer tags with no unreleased commits; versions
  read from git tag in 33-14
- release order and an empty executed-steps table
2026-11-04 08:47:58 +01:00
Jakub Zych
6ea43bb120 test: phase 33 gate script
- scripts/check-phase33.sh with the stages self-test, go, plugins,
  layers, consumers, docker, admin, docs, marker, additive and compat,
  and --all; no argument prints the usage and exits 2
- engine taken from check-phase32.sh (go test -json and vitest
  detectors, scratch sibling farm, apisurface additive comparison)
- marker diffs from v0.6.0 and adds sitemap, JSON-LD and the
  application names; additive compares against the newest tag not
  containing HEAD
- a missing repository fails with "missing <path>"; the self-test
  plants an API removal, an interface method, a marker hit, a failing
  go test and a missing repository
2026-11-04 08:47:42 +01:00
Jakub Zych
4cd35c9d66 feat(cabana): per-request options for plugin field types
- pact.FormFieldType gains RequestOptions, called with the application's
  database once per form schema request for each type the form uses
- the form schema route merges the answer into a copy of each field's
  typeOptions; a fields.yaml key wins, the compiled schema never changes
- an error logs one warning naming the type and serves the fields.yaml
  options only
- TestFieldTypeRequestOptions covers every screen, the precedence, the
  call count, the error path, unchanged schemas, form extension fields
  and the authenticated handler; a runnable example backs the docs
- the typeOptions description, the regenerated OpenAPI document and
  schema.d.ts, the field types page and the pact and cabana READMEs
  document it
2026-11-04 08:40:09 +01:00
Jakub Zych
561a56c768 feat(signpost): content descriptor fields for dates, parents, authors, media and outline
- pact.ContentDescriptor gains Kind and Tools with the ContentKind and
  ContentTool constants
- pact.ContentRecord gains PublishedAt, Parents, Authors and TopHeading;
  pact.ContentLocale gains Images, Videos and Headings, with the value
  types ContentRef, ContentPerson, ContentImage, ContentVideo and
  ContentHeading; every field is optional
- TestContentDescriptorFields proves signpost hands them through and
  that a type setting none of them describes as before
- the runnable content example, the pact and signpost READMEs and the
  menu item types page document the fields
2026-11-04 08:31:49 +01:00
Jakub Zych
399efed845 docs(20): review disposition after the gap closure
- CR-01, CR-02, CR-03, WR-01, WR-04 and WR-05 fixed, with commits, tags
  and the live checks of release 195aa213c546
- WR-02, WR-03, WR-06 and IN-01 to IN-09 deferred, one todo each
2026-11-03 23:07:32 +01:00
Jakub Zych
2035563fc7 docs(phase-20): update tracking after wave 7 2026-11-02 18:50:35 +01:00
Jakub Zych
8afc9bbc3e docs(20-08): call and session fixes summary 2026-11-02 18:50:35 +01:00
Jakub Zych
d1be8aa54d docs(20-07): filter hardening summary 2026-11-02 18:50:35 +01:00
Jakub Zych
762f0ae9aa docs(phase-20): add gap-closure plans 20-07 to 20-09 to the roadmap 2026-11-02 18:23:43 +01:00
Jakub Zych
5da9387427 docs(20): gap-closure plans for the code review findings
Claude-Session: https://claude.ai/code/session_01BK6kckmLyJ4a91Vm9rqaDP
2026-11-02 18:20:39 +01:00
Jakub Zych
f3032950be docs(20): code review of the chat family and the demo 2026-11-02 00:53:14 +01:00
Jakub Zych
a4b32b14e6 docs(phase-20): update tracking after wave 6 2026-11-02 00:38:09 +01:00
Jakub Zych
b113e12215 docs(20-06): unit tests for the chat family, two-browser checks and the live sign-off summary 2026-11-02 00:37:58 +01:00
Jakub Zych
8a3ac87abb docs(phase-20): update tracking after wave 5 2026-11-01 18:41:06 +01:00
Jakub Zych
079ef3e61e docs(20-05): chat family deployed on demo.summercms.io with its own LiveKit summary
Claude-Session: https://claude.ai/code/session_01BK6kckmLyJ4a91Vm9rqaDP
2026-11-01 18:40:42 +01:00
Jakub Zych
6389479906 docs(phase-20): update tracking after wave 4 2026-10-31 20:11:45 +01:00
Jakub Zych
d50a1575ca docs(20-04): forum, friends and video-call layers v0.1.0 on the demo site summary 2026-10-31 20:11:26 +01:00
Jakub Zych
0301dc2ae4 docs(20-04): API coverage of livekit-client, LiveKit, Centrifugo and Web Push 2026-10-31 15:07:39 +01:00
Jakub Zych
b40c519747 docs(phase-20): update tracking after wave 3 2026-10-31 14:20:33 +01:00
Jakub Zych
f1236743ae docs(20-03): vue-chat-layer v0.1.0 and chat on the demo site summary 2026-10-31 14:19:57 +01:00
Jakub Zych
baa0f20eb2 docs(phase-20): update tracking after wave 2 2026-10-30 19:13:00 +01:00
Jakub Zych
00607d165d docs(20-02): demo backend for the chat family summary 2026-10-30 19:11:58 +01:00
Jakub Zych
d6f490a9ab docs(20-02): close the sm-demo-app journal/oauthserver pointer todo
Already done by sm-demo-app 2133309 and 5ec5941 (oauthserver v0.1.6,
journal v0.2.6).
2026-10-30 18:37:35 +01:00
Jakub Zych
159cd5ecf4 docs(phase-20): update tracking after wave 1 2026-10-30 18:26:41 +01:00
Jakub Zych
2d28c9485c docs(20-01): chat family release summary 2026-10-30 18:25:54 +01:00
Jakub Zych
677e30f6f5 docs(34): record the v0.6.0 release, summercms.io and demo deploys 2026-10-30 15:54:28 +01:00
Jakub Zych
76288cd9e8 test(34): persist human verification items as UAT 2026-10-30 12:41:26 +01:00
Jakub Zych
f6d4c67314 test(34): persist human verification items as UAT 2026-10-30 12:40:44 +01:00
Jakub Zych
024fd82732 docs(34): re-verification passes, presentations ledger counts 2026-10-30 12:40:44 +01:00
Jakub Zych
c739a7dd06 docs(34): presentations v0.1.2 includes the sztab theme commit 2026-10-30 12:34:00 +01:00
Jakub Zych
211be60648 docs(34): roll out plugin.yaml to sm-presentations-plugin 2026-10-30 12:24:06 +01:00
Jakub Zych
65e0cdd1fa test(34): phase gate covers sm-presentations-plugin 2026-10-30 12:24:06 +01:00
Jakub Zych
ba39bb1125 docs(34): roll out plugin.yaml to the remaining public plugin repos 2026-10-29 23:25:08 +01:00
Jakub Zych
999054b0bd test(34): phase gate covers the remaining public plugin repos 2026-10-29 23:25:08 +01:00
Jakub Zych
87921c27f6 fix(kiosk): fetch a required plugin module whose replace directory is missing
A plugin developed inside an application replaces its required plugins
with sibling directories (../user). Checked out on its own, that
directory is missing and plugin.yaml lost the minimum (golem15.user: ""),
so the manifest differed between the two layouts and TestPluginManifest
could not pass in both. Such a module is now fetched at the version
go.mod requires, as a consumer would see it.

- listedModule.fallback marks a local replace whose directory is absent
- fallback modules download separately, so one unfetchable version does
  not hold up the others
- TestDeriveMissingReplace over a file:// proxy (offline)
2026-10-29 23:18:07 +01:00
Jakub Zych
a9237c8ec3 docs(34): phase verification report 2026-10-29 22:57:25 +01:00
Jakub Zych
7b60c17138 docs(34): code review fix report 2026-10-29 22:37:01 +01:00
Jakub Zych
75a0dea0a5 fix(34): WR-10 expect older compatible releases in the Plugins screen smoke test
The smoke fixture's acme.blog has v0.1.1 for framework v0.6.0 under a
newest release that needs v0.7.0; plugin:install picks v0.1.1, so the
marketplace now marks it compatible.
2026-10-29 22:23:05 +01:00
Jakub Zych
e6b3aa2222 fix(34): WR-11 accept an inline plugins list in plugin:add again
The comment-keeping summer.yaml edit refused a flow-style plugins list
(plugins: [{id: a, module: b}]) that plugin:add used to handle with a
full rewrite. The edit now replaces an inline list, over one or several
lines, with a block list of its items plus the new ones and keeps every
other line. A list it cannot edit in place (a tagged one, say) falls
back to the full SaveManifest rewrite with a note that the comments were
not kept. plugin:install gets the same behaviour.
2026-10-29 22:15:09 +01:00
Jakub Zych
754ab5423a fix(34): WR-10 align the marketplace compatibility marker with plugin:install
With a framework version that is not a release (a devel build, an empty
FrameworkVersion) every plugin with a minimum was marked as needing a
newer framework, and Compatible looked only at the newest release, so a
plugin whose older release fits was marked although plugin:install
would install that release. An unknown framework now gives no marker
(and no needs-framework note on the Installed tab), and a plugin whose
newest release is too new is looked up (cached, four at a time) and
counts as compatible when an older release fits.
2026-10-29 22:12:54 +01:00
Jakub Zych
7b193032c8 fix(34): WR-06 bound the Plugins screen's search cache
pluginCatalog.lists was keyed by every distinct query and category and
never dropped an expired entry, so searching distinct strings grew the
process without limit (each entry can hold a 4 MiB answer). Storing a
search into a full cache now drops the expired searches and then the
ones that expire first, keeping at most 256.
2026-10-29 22:10:41 +01:00
Jakub Zych
2f3eba0cb1 fix(34): WR-03 refuse user info in https git URLs
validateGitURL refused only a password, but GitHub, GitLab and Gitea take
a token as the user part of an https URL. Such a URL was printed and
written to .gitmodules by git submodule add, and so committed with the
application. An https URL with any user info is now refused with a hint
to use a credential helper; the user of an ssh URL stays allowed.
2026-10-29 22:09:39 +01:00
Jakub Zych
9977e31ac9 fix(34): WR-02 undo a failed plugin:install or plugin:update
A failure after the first change left the application half-modified: a
checkout moved into plugins/<vendor>/<name> but not registered, go.mod
edited, submodules without summer.yaml entries (and the next install
refused the leftover directory), or some submodules updated and others
not. Both commands now snapshot go.mod, go.sum, go.work, go.work.sum,
summer.yaml and .gitmodules (with its index entry) before applying; a
failure restores them, removes the submodules the run added (index
entry, submodule config, absorbed git dir, checkout; registered right
after the move) and checks moved submodules out where they were. What
cannot be undone is named in the error.
2026-10-29 22:08:54 +01:00
Jakub Zych
07673e4f30 fix(34): WR-01 keep summer build working for frameworks before party.Describe
summer build always emitted party.Describe and []party.Info, so an
application whose framework requirement predates v0.6.0 stopped compiling
once the CLI was updated. The build now checks the party package go list
resolves in the application (a static look for Describe and Info, not a
version compare, since a local checkout reports the previous tag until
it is tagged) and, when they are missing, writes main.go and
plugins.gen.go exactly as before, with a warning to move the framework
to v0.6.0. An unresolvable party package keeps the current sources.
2026-10-29 22:03:57 +01:00
Jakub Zych
9b72439533 fix(34): CR-02 install plugins of an installed module at its version
plugin:install pinned only modules chosen in the same plan, so a second
plugin package in a module the application already required was
installed at the newest release, moving the plugin already there, and
with --source the repository was cloned a second time. PlanInstall now
seeds every module of summer.yaml: a plugin in one installs at the
installed version with no go get or clone (only its summer.yaml entry),
and is refused with a plugin:update hint when that version has no
release of it or is not a release. A git URL whose repository is already
a submodule, or whose module the application has, is refused.
2026-10-29 22:01:30 +01:00
Jakub Zych
60551485ed fix(34): CR-01 never move a pseudo-version install back to its base release
plugin:update dropped a go.mod require that was not a strict vX.Y.Z and
treated it as v0.0.0, so a module at a commit after v0.4.0
(v0.4.1-0.<ts>-<hash>) was moved back to v0.4.0. kiosk.Newer now places
a release against a release, a git describe string or a pseudo-version
(by the tag it builds on, in Go's version order); plugin:update and the
admin Plugins screen badge share it. A pseudo-version with no tag before
its commit is left alone with a note naming go get <module>@<tag>.
2026-10-29 21:58:07 +01:00
Jakub Zych
d30ef5de47 docs(34): add code review report 2026-10-29 21:50:37 +01:00
Jakub Zych
16318b0c4a docs(34-06): roadmap plan progress 2026-10-29 21:39:42 +01:00
Jakub Zych
06ac86409a docs(34-06): complete phase gate, unit tests and release ledger plan 2026-10-29 21:39:21 +01:00
Jakub Zych
74c8146c46 docs(34): release ledger and validation map
- 34-release-log.md: v0.6.0 contents, plugin tag table (current -> new),
  release order with the three rebases, 32 pointer bumps, app
  regenerations, summercms.io deploy, v0.6.0 tag message draft, rules
- 34-VALIDATION.md: per-task map for 34-01..34-06, Wave 0 ticked
2026-10-29 21:39:21 +01:00
Jakub Zych
dfc6127009 test: phase 34 gate requires the summercms.io unit tests
- --site: the site plugin's registry unit tests by required name
- --docker: TestRegistryAPIShapes and TestRegistryIngestEndpoint
2026-10-29 21:35:00 +01:00
Jakub Zych
bad273b753 test(admin): PluginsView and settings paths
- PluginsView: tab switching, one request per typing burst and a blank
  search without q, name fallback on cards, copied state clearing, a
  refused clipboard write, a failed marketplace request
- useSettings: settingsPath prefers path, then controller, then the
  singleton form; settingsEntryForPath
2026-10-29 21:27:55 +01:00
Jakub Zych
df2ba57e7b test(cabana): Plugins screen endpoints
- TestPluginsEndpoints: 401/403/200 for both endpoints, the update rule
  table, cache hit and expiry on an injected clock, failure caching, at
  most 4 version lookups at once, call timeout, marketplace validation,
  markers, https-only icons, disabled registry making no call
- fix: marketplace categories come only from plugins the screen offers,
  not from registry entries dropped for an invalid ID
- check-phase34.sh: TestPluginsEndpoints and useSettings.test.ts required
2026-10-29 21:27:55 +01:00
Jakub Zych
a231309721 test(summer): plugin command prompts
- TestPluginCommands: plugin:install needs --yes off a terminal, --offline
  and a disabled registry refuse ID installs without registry calls,
  plugin:manifest --check exit status and output on missing and stale files
- check-phase34.sh: the 34-06 unit tests are required names of --go
2026-10-29 21:22:47 +01:00
Jakub Zych
7de774b0b3 test(build): plugin dirs, install resolution and summer.yaml edits
- TestPluginDirs and TestEffectiveFramework over go list JSON fixtures and
  git checkouts; TestResolveInstall against an in-memory registry;
  TestAppendManifestPlugins for the in-place summer.yaml edit
- fix: a package of the application's own module was reported as a
  submodule with the application's git describe; it is in-app (local)
- fix: summer.yaml items written as '-   id:' keep their spacing when
  plugin:add or plugin:install appends to them
2026-10-29 21:22:47 +01:00
Jakub Zych
9109e5b139 test(kiosk): manifest, identity, version and client unit tests
- TestReadIdentity: literal, const, chained, cross-package, alias,
  other-module and Requires shapes; refusals naming ID()/Requires()
- TestDerive, TestManifestFormat, TestCheck, TestVersions,
  TestRegistryClient (httptest: escaping, 404, envelopes, cap, redirect)
- runnable ExampleMarshal and ExampleNewestCompatible
- party TestDescribe: copy of the plugin list, publish once per app
2026-10-29 21:22:47 +01:00
Jakub Zych
b93a6de20e test: phase 34 gate script
- scripts/check-phase34.sh: --self-test, --go, --docker, --plugins, --site,
  --admin, --docs, --marker, --additive and --all over the framework, the
  20 plugin repositories and the summercms.io repositories
- required test names checked in go test -json output; vitest, TAP and
  unittest detectors; a missing repository fails with missing <path>
- docker NOT RUN fails --all unless PHASE34_ALLOW_NO_DOCKER=1
2026-10-29 21:18:00 +01:00
Jakub Zych
b5f1ed202a docs(34-02): roadmap plan progress 2026-10-29 21:02:11 +01:00
Jakub Zych
8e8e0642b0 docs(34-02): complete plugin.yaml rollout plan
- 20 plugin repositories (21 packages) carry a generated plugin.yaml and TestPluginManifest
- Task 4 answered defer: 32 app pointer bumps and push blockers recorded for the 34-06 release ledger
2026-10-29 21:01:43 +01:00
Jakub Zych
13324e7f01 docs(34-05): complete admin Plugins screen plan 2026-10-29 19:23:14 +01:00
Jakub Zych
678eac4637 build(admin): rebuild the embedded admin for the Plugins screen 2026-10-29 19:21:11 +01:00
Jakub Zych
7e8a19399d feat(cabana): plugin updates and marketplace search on the Plugins screen
- GET /plugins looks up each versioned plugin's releases through
  kiosk.Client and applies plugin:update's rule (NewestCompatible above
  the BaseVersion), with the update command and the framework a newer
  release needs
- GET /plugins/marketplace forwards q and category (validated like the
  registry), marks installed and incompatible plugins and returns the
  install command; ids outside the plugin ID shape are dropped
- 4 s per-call timeout, at most 4 lookups at once, in-process cache
  (searches 5 min, releases 15 min, failures 1 min); a failing registry
  gives status unavailable, an empty plugins.registry_url sends nothing
- SPA: update badges and Marketplace tab with copy buttons (clipboard
  fallback), hidden when the registry is disabled; no install button
- OpenAPI, SPA types, inventories, conformance case, en/pl strings, docs
2026-10-29 19:20:14 +01:00
Jakub Zych
c4b1ac4076 feat(cabana): read-only Plugins screen with installed plugins
- GET /plugins (backend.view_plugins, superusers always pass) lists the
  plugins from party.Installed, or party.Activated IDs for older builds,
  with the built framework version and whether the registry is configured
- Settings -> System -> Plugins entry with the new SettingsEntry.path,
  added only for principals allowed to open it
- PluginsView at /system/plugins with an Installed tab (reka-ui Tabs);
  settings panel, rail, breadcrumbs and page title know the route
- OpenAPI and SPA types regenerated; contract, token-class and
  conformance inventories list the route; en/pl strings; docs
2026-10-29 19:05:46 +01:00
Jakub Zych
0418900122 docs(34-04): roadmap plan progress 2026-10-29 18:48:38 +01:00
Jakub Zych
b91eadb5b6 docs(34-04): complete plugin:install and plugin:update plan 2026-10-29 18:47:57 +01:00
Jakub Zych
96a7012ece docs(34): close the --module question for v1 2026-10-29 18:46:25 +01:00
Jakub Zych
d024ab4bb8 feat(build): summer plugin:update
- module installs move to the newest compatible registry release with
  one go get; submodules fetch tags, check out the release tag and
  require it in go.mod; go.work overrides and version pins are refreshed
- unlisted submodules (git URL installs) update from their own tags and
  plugin.yaml; --offline restricts to that
- without an ID every plugin that resolves is updated; in-app, unlisted
  and incompatible ones are reported; failure only when a resolved
  update cannot be carried out
- same prompt rule as plugin:install
2026-10-29 18:46:14 +01:00
Jakub Zych
8093587696 feat(build): plugin:install --source and git URLs as submodules
- --source clones each registry repository (https only) at the chosen tag,
  adds it as a submodule under plugins/<vendor>/<name>, absorbs its git
  dir and registers it with the tag as the go.mod require
- git URLs (https, ssh, user@host:path) are cloned with the user's git
  setup and never sent to the registry; the newest tag whose plugin.yaml
  framework minimum fits is checked out, else the default branch head
- go.work replace overrides for every local replace in workspace modules'
  go.mod files (app target, versioned workspace module, or version pin)
- GIT_ALLOW_PROTOCOL=https:ssh, '--' before URLs and paths; non-git apps
  are refused before cloning; plugin:add keeps require v0.0.0
2026-10-29 18:39:22 +01:00
Jakub Zych
a462bf12c9 feat(build): plugin:install resolves requirements, pins and prompts
- required plugins resolve transitively against the registry, each at the
  newest release meeting the framework and its minimum, dependencies first
- an installed requirement below its minimum stops with a plugin:update
  hint; cycles in registry data are reported
- one go get for the whole set; plugins sharing a module share a release
- behavior subtests: deps, installed_old, cycle, pin, unknown, prompt,
  offline, invalid
2026-10-29 18:26:05 +01:00
Jakub Zych
9b23e877a6 feat(build): summer plugin:install for registry plugins as Go modules
- kiosk.Client reads the registry API v1 over fetchguard (trusted mode,
  4 MiB, 10 s, no redirects); kiosk.RegistryURL reads plugins.registry_url
  (default https://summercms.io, empty disables)
- build.PlanInstall/ApplyInstall pick the newest release the effective
  framework satisfies (or a pin), validate registry strings, go get it and
  append it to summer.yaml
- summer.yaml edits (plugin:add and plugin:install) keep comments and
  blank lines
- TestInstallSmoke/module installs from a file:// GOPROXY and builds
2026-10-29 18:22:01 +01:00
Jakub Zych
e7eda88ddd docs(34-03): roadmap plan progress 2026-10-29 18:11:36 +01:00
Jakub Zych
77ac19a57c docs(34-03): complete summercms.io registry and marketplace plan 2026-10-29 18:11:05 +01:00
Jakub Zych
69c1fea766 docs(34-01): roadmap plan progress 2026-10-29 17:14:15 +01:00
Jakub Zych
f1f1d55fa7 docs(34-01): complete plugin.yaml, registry contract and build metadata plan 2026-10-29 17:13:43 +01:00
Jakub Zych
e8d3d724f5 feat(build): bake plugin metadata and the framework version into plugins.gen.go
- party.Info, party.Installed, party.Describe (additive; Plugin and Activate unchanged)
- summer build resolves plugin dirs with one go list, reads plugin.yaml, versions per D-07 (module version, git describe without --dirty, none for in-app plugins)
- build.EffectiveFramework: newest tag of a locally replaced or used framework checkout, else the go.mod require
- generated main calls party.Describe(app, PluginInfo, FrameworkVersion) before Activate; metadata failures warn and fall back to IDs
- summer new goldens and examples/hello regenerated
2026-10-29 17:12:13 +01:00
Jakub Zych
4916ee3d43 feat(kiosk): registry v1 wire types and version rules
- RegistryAPIPath, RegistryPlugin, RegistryPluginDetail, RegistryVersion, envelopes, errors, ingest types
- ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible
- docs: Registry API v1 reference in plugins/marketplace.md
2026-10-29 17:05:41 +01:00
Jakub Zych
51ec2f9daa feat(kiosk): resolve constant plugin IDs, overrides and the CheckT helper
- kiosk.ReadIdentity: ID()/Requires() as literals or constants across packages and modules, read with go/parser, never run
- plugin minimums from go.mod requires; v0.0.0 is an error, a pseudo-version warns
- kiosk.Validate, kiosk.CheckT (no testing import)
- plugin:add reads IDs through kiosk.ReadIdentity, so const-ID plugins register
2026-10-29 17:02:38 +01:00
Jakub Zych
ec6a1955fc feat(kiosk): plugin.yaml manifest and summer plugin:manifest
- new module kiosk: Manifest, Marshal, Parse, Derive, Generate, Write, Check, StaleError
- plugin:manifest [dir] with --check, --pretend, --name, --description
- docs: kiosk README, root table row, plugins/marketplace.md, scaffolding row
2026-10-29 16:55:17 +01:00
Jakub Zych
824e3d5882 docs(34): create phase plan 2026-10-29 13:27:49 +01:00
Jakub Zych
ac9f6db03e docs(phase-34): add research and validation strategy 2026-10-29 12:47:47 +01:00
Jakub Zych
c48301ef7e docs(34): capture phase context 2026-10-29 12:16:38 +01:00
Jakub Zych
4449066fa6 docs: capture exploration — plugin marketplace (note, research question, Phase 34) 2026-10-29 11:51:12 +01:00
Jakub Zych
b651a67356 docs(quick-261029-d9j): user plugin mail locale from Accept-Language, fallback key, Polish subjects 2026-10-29 09:49:36 +01:00
Jakub Zych
e51df97db8 docs(quick-261029-1r3): derive the summercms.io landing version from go.mod, close its todo 2026-10-29 01:32:38 +01:00
Jakub Zych
0e5bb0c44f docs(quick-261028-wqu): summercms.io changelog dropdown with Framework and Plugins streams 2026-10-29 00:31:21 +01:00
Jakub Zych
bac8e7ef22 docs: plugin release posts go to /changelog/plugins 2026-10-29 00:25:34 +01:00
Jakub Zych
83a952a74b docs: capture todo - Stream-aware previous/next links on the summercms.io changelog 2026-10-29 00:05:21 +01:00
Jakub Zych
545c0b4350 docs: record the version-format decision on the landing-version todo 2026-10-29 00:05:08 +01:00
Jakub Zych
13aa5f3227 docs: capture todo - Derive the summercms.io landing version from go.mod 2026-10-28 23:41:08 +01:00
Jakub Zych
860c41d829 docs(quick-batch-261028-rqa): 6 framework items complete, close their todos, record follow-ups 2026-10-28 21:51:41 +01:00
Jakub Zych
6c606c666b feat(diary): boot list, help and completion requests quietly (quick-261028-rqc)
- helpOnly also counts no command word, list, help, cobra's shell
  completion requests and --help=<bool>, read the way pflag does
- TestHelpOnlyAgreesWithCobra pins the classification against bonfire's
  real dispatch, with the passthrough --help case as the exception
- console and logging docs describe which invocations boot quietly
2026-10-28 21:47:42 +01:00
Jakub Zych
6b9becd994 feat(diary): boot --help invocations with the console at warn (quick-261028-rqc)
- Options.Args holds the command-line arguments; FromGlobals fills it
  from os.Args[1:], so the generated main needs no change
- Bootstrap and Setup install the setup with Quiet when Args only ask
  for help (-h, --help) and no -v is given; files keep their levels
- TestBootQuietSmoke runs the generated main's sequence with a plugin
  that logs from Register and Boot
2026-10-28 21:43:36 +01:00
Jakub Zych
653d95a5b9 test(cabana): cover the Roles form's holders list (quick-261028-rqg)
- TestRolesHoldersPartial (Docker-free): users partial field, view model
  guard, links, state lines, escaped strings, empty line, note
- TestRolesHolders (Postgres): D-03 hiding, links by manage_users, states,
  empty role, 100-row cap with note, 403 without manage_roles
2026-10-28 21:37:22 +01:00
Jakub Zych
8cb56a4c51 feat(cabana): list the administrators holding a role on the Roles form (quick-261028-rqg)
- users form partial on the Administrators tab of the role update form,
  served by rolesAdmin.PartialData through scopeAdministrators (D-03)
- deleted and deactivated holders marked, superusers only for superuser
  editors, first 100 rows with a count note, preview links for editors
  holding backend.manage_users
- en/pl phrases, README and docs, TestRolesSmoke asserts the partial
2026-10-28 21:32:19 +01:00
Jakub Zych
c8a22e3e84 test(boardwalk): cover the build id, the reload banner and the chunk reload guard (quick-261028-rqb) 2026-10-28 21:26:20 +01:00
Jakub Zych
75e19dc153 chore(boardwalk): rebuild the embedded admin for the reload banner and chunk guard (quick-261028-rqb) 2026-10-28 21:25:18 +01:00
Jakub Zych
64452a4919 feat(admin): reload once when a chunk fails to load after a deploy (quick-261028-rqb)
- installChunkReloadGuard reloads on vite:preloadError, at most once per
  60 s through a sessionStorage timestamp; no storage means no reload
2026-10-28 21:21:28 +01:00
Jakub Zych
291b18fb1e feat(boardwalk): expose the admin build id and show a reload banner when a new build is deployed (quick-261028-rqb)
- boardwalk.BuildID hashes the dist tree plus the binary's build info once
  per handler; X-Summer-Build on every response, {prefix}/build.json and the
  summer-admin-build meta carry it
- the SPA reads the booted build, re-checks on focus, visibility and every
  5 minutes, and shows UpdateBanner on a different build
2026-10-28 21:19:36 +01:00
Jakub Zych
4957e5ce25 refactor(surf): take the uploads inline types from attach.IsInlineContentType (quick-261028-rqe)
- the route's private inline list and its helper are gone; the
  disposition writer asks attach.IsInlineContentType, the same
  definition attach.Store uses for the disposition it stores
- headers are unchanged (TestUploadsResponseHeaders, TestUploadsMount)
- surf README and the storage page name the shared function
2026-10-28 21:12:46 +01:00
Jakub Zych
ba8432cfb8 fix(lagoon/attach): store non-inline uploads with Content-Disposition attachment (quick-261028-rqe)
- attach.IsInlineContentType: the shared inline raster list (JPEG, PNG,
  GIF, WebP, AVIF), parameters and case ignored, false for empty or
  unparsable values
- writeBlob stores Content-Disposition: attachment for every other type,
  so a public bucket or CDN makes the browser download HTML, SVG, PDF or
  plain text; content_type and thumbnails are unchanged
- driver tests on memblob and fileblob, Postgres-backed Store assertion,
  lagoon README and the storage and attachments pages
2026-10-28 21:10:48 +01:00
Jakub Zych
61d10436c7 feat(cabana): log successful toolbar and widget action runs with the admin id (quick-261028-rqf)
- runAction writes "cabana: admin action run" with controller, action,
  admin_id and kind (toolbar or field; field name and record id for a
  widget), never values, payload, answer or record contents
- the failure and data-rejected lines carry the same keys (admin_id, kind)
- actionAdminID helper shared with the bulk and record lines (unchanged)
- TestAdminActionRunLog pins the line on the acme.demo fixture (Postgres)
- README and the permissions docs describe the run log
2026-10-28 21:05:55 +01:00
Jakub Zych
202d38ba3c docs(quick-261028-rqd): record the console info findings as fixed and the earlier gates' consumer names as a todo 2026-10-28 20:57:22 +01:00
Jakub Zych
10fec478be fix(scripts): read the sibling application paths and names from the environment in the 19.8 gate (IN-09, quick-261028-rqd) 2026-10-28 20:56:23 +01:00
Jakub Zych
2da42e93f9 docs(console): say that -q hides the Created and Added lines (IN-05, quick-261028-rqd) 2026-10-28 20:53:26 +01:00
Jakub Zych
bb7e14bce9 fix(summer): exit 128+n when a delegated binary dies by a signal (WR-10, quick-261028-rqd) 2026-10-28 20:52:57 +01:00
Jakub Zych
4914cade5a fix(summer): send a delegate's stderr to the root's error writer through bonfire.ErrWriter (IN-13, quick-261028-rqd) 2026-10-28 20:50:39 +01:00
Jakub Zych
f3f87f3cc4 refactor(diary): share the console types through internal/consolekit so diary no longer links bonfire (IN-07, quick-261028-rqd) 2026-10-28 20:46:32 +01:00
Jakub Zych
3520d5d480 fix(diary): skip HideOnTTY records only on a terminal console, also with forced color (IN-01, quick-261028-rqd) 2026-10-28 20:41:07 +01:00
Jakub Zych
f91d70d05e fix(bonfire): kill a live view on a signal only after it has started (IN-12, quick-261028-rqd) 2026-10-28 20:37:30 +01:00
Jakub Zych
6efb9a4997 fix(bonfire): print Error lines above a running live view (IN-10, quick-261028-rqd) 2026-10-28 20:35:34 +01:00
Jakub Zych
4d75695582 fix(bonfire): keep a blank line printed during a live view (IN-11, quick-261028-rqd) 2026-10-28 20:34:35 +01:00
Jakub Zych
795c4f6b29 feat(bonfire): add FlagBool and read every bare switch with it (IN-04, quick-261028-rqd) 2026-10-28 20:32:38 +01:00
Jakub Zych
e34d113c80 fix(bonfire): read the =value forms of the global flags and use the parsed globals in the error block (IN-02, quick-261028-rqd) 2026-10-28 20:29:16 +01:00
Jakub Zych
6294ac4141 docs(quick-261028-rqd): record that bonfire dropped fang 2026-10-28 20:26:35 +01:00
Jakub Zych
53c0a65a43 refactor(bonfire): run the root without fang so bonfire's root help stays installed (IN-06, quick-261028-rqd) 2026-10-28 20:26:20 +01:00
Jakub Zych
39c59b8329 fix(bonfire): recognise cobra's unknown command error once in Execute (IN-03, quick-261028-rqd) 2026-10-28 20:23:39 +01:00
Jakub Zych
8476a65aa0 fix(bonfire): replace invalid UTF-8 before the control scan in Sanitize (IN-08, quick-261028-rqd) 2026-10-28 20:21:19 +01:00
Jakub Zych
825a3fd2da docs(quick-261028-r16): close the two lagoon todos, record the fonoteka.go and S3 follow-ups 2026-10-28 19:47:44 +01:00
Jakub Zych
36092f542c fix(lagoon): warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and pivot read guidance (quick-261028-r16)
- flushStatementAfterCommit marks the statement; AfterCommit logs a warning
  when work is buffered on a flushed statement that no longer holds GORM's
  transaction. Log-only: the work still never runs, the write succeeds
- subtests pin the late callback warning, a callback between commit and
  flush, and a reused chained statement staying silent
- README OnDatabase example registers After(gorm:create).Before(commit),
  matching docs/database/transactions.md
- RegisterJoinTable: pivot-ordered reads join the pivot table explicitly;
  a Preload cannot be ordered by a pivot column
2026-10-28 19:45:57 +01:00
Jakub Zych
ebdfd78ab4 docs(storage): pin the script-chunk PNG upload and split the uploads deploy note (quick-261028-r16)
- TestPNGScriptChunkNaming (no Docker) and TestStoreScriptChunkPNG (Postgres)
  pin the todo exploit: a decodable PNG with a script tEXt chunk uploaded in
  image mode as x.html is stored and served as .png, bytes unchanged
- storage deploy note separates static servers (nosniff, nginx extension
  allow-list, 404 otherwise) from public buckets, which send the stored
  sniffed Content-Type; attachments.md points to it
2026-10-28 19:42:28 +01:00
Jakub Zych
6ea2cb70e0 fix(build): keep an existing toolchain line instead of adding a second one
make:*, plugin:add and new inserted toolchain go1.27.0 into go.mod and
go.work whenever that exact line was missing. A file that already named
another toolchain (go mod tidy and go work use write the running one)
ended up with two toolchain lines, and Go refused to load it with
"repeated toolchain statement". ensureToolchain now keeps an existing
line, replaces it only when it names an older toolchain, and inserts
one only when there is none.
2026-10-28 19:42:20 +01:00
Jakub Zych
8c9c967c88 docs: close todo and debug session - user logout leaves the session cookie valid 2026-10-28 19:24:43 +01:00
Jakub Zych
1a487bc644 docs(quick-261028-pq8): fix the four internal/build scaffolder todos 2026-10-28 19:18:29 +01:00
Jakub Zych
8bc18c90b6 feat(build): scaffold working admin controllers with model, AdminFS and delete (quick-261028-pq8)
- make:admin-controller edits the singular of its name (--model overrides):
  ModelName, modelClass and the YAML under models/<model>/
- NewRecord returns &models.<Model>{} when the model exists; otherwise the
  nil body stays and the command says to run make:model
- config_list.yaml offers create and delete with showCheckboxes
- registry.gen.go embeds controllers/*/*.yaml and models/*/*.yaml once they
  exist and exposes generatedAdminFS(); scaffolded plugins implement
  pact.AdminAssets through it, and a plugin without AdminFS gets a note
- blog example moves its YAML to models/post; docs and help text follow
2026-10-28 19:15:23 +01:00
Jakub Zych
70e4b4183c feat(build): mark scaffolded files with //summer:make and pass the app to generated commands (quick-261028-pq8)
- model, migration, command, job and admin-controller stubs start with
  //summer:make <kind>; registry.gen.go keeps its generated-code header
- the registry scan accepts the directive and the older header
- command functions may take one *backpack.App; generatedCommands takes a
  variadic app, so plugin.go files calling generatedCommands() still compile
- scaffolded plugins keep the app from Boot and return generatedCommands(p.app)
- make:command notes the nil app for an older plugin.go
- docs, help text and the blog example follow
2026-10-28 19:04:39 +01:00
Jakub Zych
a95a014b52 fix(build): scaffold plugins under plugins/<vendor>/<name> (quick-261028-pq8)
- make:plugin writes plugins/<vendor>/<name> with module
  <app>/plugins/<vendor>/<name>; the package name stays <name>
- make:* and the models-leaf check look in summer.yaml, then
  plugins/<vendor>/<name>, then the older plugins/<name>
- summer new puts its starter plugin at plugins/golem15/<name>
- refuse a vendor directory that is a legacy plugin module and a second
  copy of a plugin that already sits in plugins/<name>
- help text and docs, with an upgrading note: moving is optional
2026-10-28 18:55:46 +01:00
Jakub Zych
a941ab2394 fix(build): order scaffolded migrations after every existing one (quick-261028-pq8)
- nextMigrationFile starts at one second past the newest updates/ timestamp
  when that is not earlier than now, so registry order is creation order
- frozen-clock unit test and a registry-order check in the scaffold test
- docs: drop the same-second caveat and the manual order check
2026-10-28 18:49:17 +01:00
Jakub Zych
017e0e9a6a docs(20): create phase plan 2026-10-28 18:39:49 +01:00
Jakub Zych
2bef465ecd docs: capture todo - make:plugin drops the vendor namespace 2026-10-28 18:24:53 +01:00
Jakub Zych
d788d12785 docs(phase-20): add pattern map 2026-10-28 17:55:35 +01:00
Jakub Zych
341a3a51a1 docs(phase-20): record plan-time decisions D-20..D-23 2026-10-28 17:52:58 +01:00
Jakub Zych
b5a20996c1 docs(phase-20): add research and validation strategy 2026-10-28 17:50:49 +01:00
Jakub Zych
1488276bd2 docs(20): capture phase context 2026-10-28 17:25:56 +01:00
3cc42f5079 docs(phase-32.1): record the MCP OAuth decision 2026-10-28 17:05:36 +01:00
1468344460 docs(phase-32.1): complete phase execution 2026-10-28 17:05:19 +01:00
040187deb9 docs(32.1): record code review disposition 2026-10-28 17:03:08 +01:00
bba6539883 docs(32.1-05): complete the unit tests and release plan
- Record the tag-and-publish decision, changelog notes, and roadmap check
- Leave the release decision file unchanged
2026-10-28 16:44:14 +01:00
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
578 changed files with 82963 additions and 1216 deletions

View File

@@ -192,6 +192,7 @@ Validated in Phase 18.1: Payment Gateway Subscriptions
| OIDC on wristband with golang-jwt v5, not zitadel/oidc (Phase 19.4, `notes/2026-10-19-oidc-on-wristband-golang-jwt.md`) | wristband already owned the OAuth flows and PHP-compatible encoding; zitadel's own storage and routing would fight both. go-oidc and x/oauth2 are test-only, for relying-party interop | ✓ Good (Phase 19.4 UAT, 2026-10-20) |
| PHP OIDC parity is recorded at the WavePath cutover, not in Phase 19.4 (D-14 amended) | The harness ships and replays Go-side; recording needs the live PHP site, and WavePath's provider must not move until the replay is green | — Pending (todo `2026-10-19-record-php-oidc-parity-against-wavepath`) |
| Administrators and Roles live in cabana as an internal pseudo-plugin; privilege rules run in the save transaction; contributed Administrators Runs are wrapped with `guardOutranked` | WinterCMS screens were missing and 19.7 needs the record-action seam; a delegated editor must not outrank the target or capture a code-declared role | ✓ Good (Phase 19.6.1 UAT 26/26, 2026-10-22) |
| MCP OAuth advertises one scope list, puts the MCP subject on the request, and stores one consent row per user and client (Phase 32.1) | Declared member-token scopes join the wristband and pier lists; an empty wristband list stays the five fixed names. `Implies` expands only for OAuth MCP subjects. A decision replaces the stored scopes, revoke deletes that client's MCP codes, and the one-time backfill rolls back as a no-op | ✓ Good (framework v0.5.2, sm-oauthserver-plugin v0.1.4, sm-user-plugin v0.1.16; vue-oauth-layer v0.1.0 is local only, 2026-10-28) |
## Evolution
@@ -211,4 +212,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state
---
*Last updated: 2026-10-22 after Phase 19.6.1 completion*
*Last updated: 2026-10-28 after Phase 32.1 completion*

View File

@@ -1270,7 +1270,7 @@ Plans:
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 6/6 plans executed
**Plans:** 8/9 plans executed
Plans:
**Wave 1**
@@ -1533,10 +1533,33 @@ Plans:
3. The demo is deployed publicly: the frontend is open, `/backend` is not reachable from the public internet, and the site carries a "want to see the backend? contact Golem15" call to action.
4. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 0 plans
**Plans:** 6/6 plans executed
Plans:
- [ ] TBD (run /msd-plan-phase 20 to break down)
**Wave 1**
- [x] 20-01-PLAN.md — shared plugin release: siblings fast-forwarded, chat and forum pre-save seams (D-18), new sm-moderation-plugin (D-17), go.mod fixes, eight tags after go-ahead
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 20-02-PLAN.md — demo backend: oauthserver bump (folded todo), eight plugins mounted by tag, demo context and channel adapter, bootstrap endpoints, realtime for members, slash commands, moderation wiring, EN/PL seed and reset, Centrifugo namespaces, local LiveKit
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 20-03-PLAN.md — vue-chat-layer v0.1.0 (bm port, channels, threads, pins, command picker, link embeds) and /chat, floating panel, Chat tour card in vue-demo-app
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 20-04-PLAN.md — vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer v0.1.0 (DM-only call button), their pages and tour cards, herald VAPID config without UI
**Wave 5** *(blocked on Wave 4 completion)*
- [x] 20-05-PLAN.md — rome deploy: the demo's own LiveKit, Vuurmuur rule (checkpoint), nginx, .env keys, Centrifugo, release, DEPLOY.md, check-deploy and check-live
**Wave 6** *(blocked on Wave 5 completion)*
- [x] 20-06-PLAN.md — unit tests (Go, layers, site), two-browser chat and DM call checks, phase gate and live verification
**Wave 7** *(gap closure, code review)*
- [x] 20-07-PLAN.md — filter hardening (CR-02, CR-03): normalised text and canonical link hosts in sm-moderation-plugin, rendered-HTML check in sm-forum-plugin, bypass regression tests
- [x] 20-08-PLAN.md — call and session fixes (CR-01, WR-01, WR-04, WR-05): useCall cancellation, join confirmation, failed-connect end, chat link guard, logout reset
**Wave 8** *(blocked on Wave 7 completion)*
- [ ] 20-09-PLAN.md — release, redeploy and recheck: suites, patch tags, demo bumps, rome deploy, live bypass and call probes, changelog posts, disposition ledger
### Phase 30: Płytarium cutover
@@ -1595,7 +1618,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 +1637,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:** 5/5 plans complete
**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)*
- [x] 32.1-05-PLAN.md — Unit tests, then the release checkpoint
### Phase 33: SEO output, audit and content
@@ -1640,10 +1692,78 @@ 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:** 16/16 plans executed
Plans:
- [ ] TBD (run /msd-plan-phase 33 to break down)
**Wave 1**
- [x] 33-01-PLAN.md — framework: additive content descriptor fields and per-request field options, check-phase33.sh, v0.5.2 release log (no tag)
- [x] 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)*
- [x] 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
- [x] 33-03-PLAN.md — sm-sitemap-plugin: sitemap index with per-type files, slot table, real lastmod, hreflang, filter contract (breaking, D-32)
- [x] 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)*
- [x] 33-04-PLAN.md — sm-sitemap-plugin: image and News sitemaps, IndexNow, HTML-sitemap API, MCP tools
- [x] 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)*
- [x] 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)*
- [x] 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)*
- [x] 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)*
- [x] 33-11-PLAN.md — internal linking and media: auto-links, link suggestions (E10), WebP variants with srcset
**Wave 8** *(blocked on Wave 7 completion)*
- [x] 33-12-PLAN.md — apps I: demo end to end on local links, summercms.io patches, sm-bm-app noindex (D-72)
- [x] 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)*
- [x] 33-14-PLAN.md — unit tests, check-phase33.sh --all, release tags in order, app re-pins, changelog post
**Wave 10** *(gap closure after the 33-14 release checkpoint; 33-14 Task 4 resumes after both)*
- [x] 33-15-PLAN.md — Go plugins: route report noindex paths and the filter (G1), Journal descriptors per translated locale (G3), crawl lifecycle and addresses, sitemap IndexNow/lock/lastmod/migration/headings, Journal content fixes
- [x] 33-16-PLAN.md — Nuxt layers: route report noindex paths (G1), HTML sitemap pages (redirect, catch-all, i18n), root files 503, post article meta, journal pagination routes, link: release guard and versions
### Phase 34: Plugin marketplace
**Goal:** People browse the public Golem15 plugins at summercms.io/marketplace and add one to their application with a single command, `summer plugin:install <id>`. The same command installs private plugins straight from a git URL without contacting summercms.io. The admin SPA gains a read-only Plugins screen listing installed plugins and searching the marketplace, which shows the install command with a copy button. v1 has no paid plugins, no developer registration and no installs from the admin panel.
**Requirements**: TBD
**Depends on:** Nothing technical; ordered after Phase 33
**Repos:** `summercms.go` (manifest spec and validation, `plugin:install`, cabana Plugins screen and boardwalk views), `sm-summercmsio-app` and its `golem15.summercms` site plugin (registry API, ingestion, admin presentation fields), `vue-summercmsio-app` (marketplace pages), and a `plugin.yaml` in each public `sm-*-plugin` repo.
**Context:** decisions in `.planning/notes/2026-10-29-plugin-marketplace.md`; open question on `--module` for private git URLs in `.planning/research/questions.md`.
**Success Criteria** (what must be TRUE):
1. Every public `sm-*-plugin` repo carries a `plugin.yaml` manifest (ID, module path, name, description, required framework version, required plugins) that the framework validates, and anyone can fetch the repo anonymously.
2. summercms.io ingests manifest, README and versions when a plugin is tagged, serves them through a registry API, and renders /marketplace with list, search, category and detail pages; category, featured, screenshots and icon are edited in the summercms.io admin.
3. `summer plugin:install golem15.<name>` resolves the plugin through the registry and installs it as a Go module (`--source` checks it out as a submodule); `summer plugin:install <git-url>` installs from the repo alone as a submodule. Both register the plugin like `plugin:add` and check framework and plugin requirements.
4. The admin Plugins screen lists installed plugins with versions and available updates, and its Marketplace tab searches the registry and shows the install command with a copy button.
5. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 6/6 plans executed
Plans:
**Wave 1**
- [x] 34-01-PLAN.md — kiosk module: plugin.yaml format, `summer plugin:manifest [--check]`, const-resolving ID reader, registry v1 wire types, PluginInfo/FrameworkVersion baked by summer build (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 34-03-PLAN.md — summercms.io registry: tables, pull ingester, /api/registry/v1, admin curation, `registry` scope ingest-now, hourly schedule, /marketplace pages, deploy, changelog-post.py hooks (wave 2)
- [x] 34-04-PLAN.md — `summer plugin:install` (module, --source, git URL) and `summer plugin:update`, `plugins.registry_url`, go.work overrides (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 34-05-PLAN.md — admin Plugins screen: cabana endpoints, backend.view_plugins, Installed + Marketplace tabs with copy, openapi and dist (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 34-02-PLAN.md — plugin.yaml and TestPluginManifest in every public sm-*-plugin repo, framework minimum v0.6.0, app pointer bumps after Jakub's push (wave 4)
**Wave 5** *(blocked on Wave 4 completion)*
- [x] 34-06-PLAN.md — unit tests across all repos, scripts/check-phase34.sh, release ledger and validation map (wave 5)
## Progress
@@ -1695,11 +1815,12 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 19.6.1. Backend Administrators screen (INSERTED) | 5/5 | Complete | 2026-10-22 |
| 19.7. Generic MCP module | 9/9 | In Progress| |
| 19.8. Console commands overhaul (INSERTED) | 6/6 | In Progress| |
| 20. Demo instance with every shared plugin | 0/TBD | Not started | - |
| 20. Demo instance with every shared plugin | 6/6 | In Progress| |
| 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 | 16/16 | In Progress| |
| 34. Plugin marketplace | 6/6 | In Progress| |
## Backlog
@@ -1723,3 +1844,33 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
Plans:
- [ ] TBD (promote with /gsd-review-backlog when ready)
### Phase 999.2: SEO follow-ups after Phase 33, for v0.6.2 (BACKLOG)
**Goal:** Clear what Phase 33 deferred:
- the journal-tag drift warning while tag pages are off (G2), which needs an additive ContentDescriptor field such as `Routable` and signpost.Activate accepting a type without a URL pattern;
- the deferred warnings and info items in 33-REVIEW.md and 33-REVIEW-GAPFIX.md, listed under "Deferred" in 33-15 and 33-16:
- sitemap: types sharing a group slug, no 50 MB byte guard, Additional addresses on another host;
- seo: long crawls held in memory and running past the job timeout, recheck inside an open transaction;
- journal layer: card image `sizes`, the sitemap group home crumb;
- journal plugin: the author login in URLs, the alt batch with locale "" next to the default locale, the settings API's own url check;
- a post-release mode for `check-phase33.sh --consumers`;
- the grzyby DEPLOY.md Verify line expecting `X-Robots-Tag: index, follow`;
- a decision for untranslated locale addresses, which still serve default-locale content under their own canonical: point the canonical at the default locale, or answer 404.
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (promote with /msd-review-backlog when ready)
### Phase 35: Mail templates and layouts editor
**Goal:** Administrators edit every mail the application sends from the admin panel, as in WinterCMS's Mail templates screen. System mail and the templates plugins register through `pact.HasMailTemplates` appear in a list. Each template can get a database override of its subject and body, and the override can be reset to the file version. Layouts become admin-editable records assigned to templates, with a few predefined layouts seeded. Images can be uploaded and placed in layouts and templates. postcard renders the override when one exists, otherwise the plugin's file, so mail that already works keeps working.
**Requirements**: TBD
**Depends on:** Phase 34
**Context:** postcard already loads plugin templates and layouts from `views/mail/` (`pact.HasMailTemplates`, `postcard.BootPlugin`); this phase adds the editable database layer, admin screens and image handling on top. To confirm in discussion: partials (WinterCMS mail partials), preview and test send, per-locale variants, and whether the screens live in cabana or in a plugin.
**Plans:** 0 plans
Plans:
- [ ] TBD (run /msd-plan-phase 35 to break down)

View File

@@ -1000,6 +1000,12 @@ Recent decisions affecting current work:
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
- Phase 19.4: review warnings WR-01..06 are open (19.4-REVIEW-DISPOSITION.md); WR-01 (a replayed code does not revoke rotated refresh tokens) must be fixed before any client enables refresh. Release still pending: tag summercms, bump sm-oauthserver-plugin go.mod to sm-user-plugin v0.1.1 / summercms v0.2.4.
| 261028-rqb | Admin SPA reload on deploy (todo .planning/todos/pending/2026-10-11-reload-open-apps-when-a-new-version-is-deployed.md, admin half only; site half already shipped in vue-demo-app): boardwalk exposes a build version derived from the embedded dist (header like X-Summer-Build on admin API responses and/or a version endpoint), the admin SPA records the version it booted with and shows a non-blocking reload banner when a newer build is seen (focus/visibility/interval check), plus a reload-once guard on Vite chunk-load errors (vite:preloadError + sessionStorage flag); unit tests; boardwalk README; rebuild admin and commit modules/boardwalk/dist (scripts/check-admin-dist.sh must pass) | 2026-10-28 | c8a22e3 | .planning/quick/261028-rqb-admin-spa-reload-on-deploy-todo-planning-todos-pending-2026 |
| 261028-rqc | Quiet plugin boot logs on console commands like --help, framework side only (todo .planning/todos/pending/2026-10-21-quiet-plugin-boot-logs-on-console-commands.md): help/list/completion style commands must not print plugin Register/Boot Info logs (e.g. raise the default console log level for non-serve introspection commands or defer Boot for them), without hiding logs for serve/worker/migrate; no sm-* plugin edits (plugin-side Info->Debug changes are a separate follow-up); docs for the behaviour | 2026-10-28 | 6c606c6 | .planning/quick/261028-rqc-quiet-plugin-boot-logs-on-console-commands-like-help-framewo |
| 261028-rqd | Console review info findings IN-01..IN-13 and the delegate signal exit code 128+n (todo .planning/todos/pending/2026-10-22-console-review-info-findings.md, review .planning/phases/19.8-console-commands-overhaul/19.8-REVIEW.md); all 14 verified still open on 2026-10-28 in modules/bonfire, modules/diary, cmd/summer, scripts/check-phase19.8.sh; fix each, keep behaviour backward compatible, update docs/console pages and TestDelegateExitCodePassthrough for 128+n | 2026-10-28 | 202d38b | .planning/quick/261028-rqd-console-review-info-findings-in-01-in-13-and-the-delegate-si |
| 261028-rqe | attach.Store writes non-inline types with a non-rendering disposition (todo .planning/todos/pending/2026-10-28-attach-store-public-content-type.md): in modules/lagoon/attach/store.go pass Content-Disposition attachment (blob.WriterOptions) for anything not in an inline-safe raster allow-list (jpeg, png, gif, webp, avif), keep the DB ContentType unchanged, additive and non-breaking; tests; lagoon README and docs/services/storage.md deploy note updated | 2026-10-28 | 4957e5c | .planning/quick/261028-rqe-attach-store-writes-non-inline-types-with-a-non-rendering-di |
| 261028-rqf | Audit-log successful admin action runs with the admin id (todo .planning/todos/pending/admin-action-audit-log.md): in modules/cabana/actions.go runAction log success with admin_id (bouncer.User(ctx).ID), controller, action and kind (toolbar/field), matching the existing bulk/record action log fields; tests | 2026-10-28 | 61d1043 | .planning/quick/261028-rqf-audit-log-successful-admin-action-runs-with-the-admin-id-tod |
| 261028-rqg | Show the administrators holding a role on the Roles screen (todo .planning/todos/pending/roles-screen-holders-list.md, origin .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UAT.md:44): read-only list of holders on the Roles form/preview in modules/cabana backend_roles_admin.go (and admin SPA if needed), superusers hidden from non-superuser editors (D-03), assignment stays on the Administrators form; tests; if admin/ changes, rebuild and commit modules/boardwalk/dist | 2026-10-28 | 653d95a | .planning/quick/261028-rqg-show-the-administrators-holding-a-role-on-the-roles-screen-t |
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
@@ -1059,6 +1065,14 @@ Recent decisions affecting current work:
| 261022-wah | User plugin opt-in golem15.user.jwt.token_cookie (default off) sets the HttpOnly auth.cookie session cookie on /login, /register and /activate-by-code instead of the body token; a /refresh carried by the auth.cookie cookie rewrites it (sm-user-plugin 846087d, 8bdf2c7, ships v0.5.0; verified) | 2026-10-22 | 846087d, 8bdf2c7 (sm-user-plugin) | [261022-wah-opt-in-httponly-session-cookie-on-user-p](./quick/261022-wah-opt-in-httponly-session-cookie-on-user-p/) |
| 261022-x7a | User plugin golem15.user.password.public_routes (default true): false leaves POST /login, /forgot-password and /reset-password unregistered so they 404 like unknown paths; /register and the rest unchanged (sm-user-plugin 3828a65, ships v0.5.0; verified) | 2026-10-22 | e5dd457 | [261022-x7a-user-plugin-golem15-user-password-public](./quick/261022-x7a-user-plugin-golem15-user-password-public/) |
| 55 | fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged after commit | 2026-10-23 | 7c43820 | — |
| 261028-pq8 | Fix the four internal/build scaffolder todos (make:plugin vendor path, make:admin-controller output, scaffold header and make:command deps, same-second migration order) | 2026-10-28 | 8bc18c9 | [261028-pq8-fix-the-four-internal-build-scaffolder-t](./quick/261028-pq8-fix-the-four-internal-build-scaffolder-t/) |
| 261028-r16 | Fix the two lagoon todos: stored-upload extension from sniffed MIME (tests + deploy note) and the lagoon README after-commit callback order | 2026-10-28 | 36092f5 | [261028-r16-fix-the-two-lagoon-todos-derive-stored-u](./quick/261028-r16-fix-the-two-lagoon-todos-derive-stored-u/) |
| fast | ensureToolchain keeps an existing toolchain line instead of adding a second one | 2026-10-28 | 6ea2cb7 | — |
| 261106-dv0 | shared plugins log boot details at Debug: paymentgateway operators registered, paymentgatewaysubscriptions registries collected through diary.For (todo 2026-10-28-plugins-log-boot-details-at-debug.md) | 2026-11-06 | 8a88b86, 1df6a26 | [261106-dv0-plugin-boot-logs-at-debug](./quick/261106-dv0-plugin-boot-logs-at-debug/) |
| 261106-e5v | purge token rename leftovers: grzybyfunkcjonalne check-deploy refuses NUXT_SITE_* keys, journal/menu/faq READMEs name NUXT_CACHE_PURGE_TOKEN (todo 2026-10-20-rename-site-cache-purge-env-in-consumers.md) | 2026-11-06 | 4e2cf0c, f61c90a, 128a33d, a53511b | [261106-e5v-purge-token-rename-leftovers](./quick/261106-e5v-purge-token-rename-leftovers/) |
| 261106-jhk | wristband MCP authorize drops OIDC identity scopes (openid, profile, email) so the ChatGPT connector can connect | 2026-11-06 | aba678b | [261106-jhk-wristband-mcp-authorize-ignores-oidc-ide](./quick/261106-jhk-wristband-mcp-authorize-ignores-oidc-ide/) |
| 261106-kt1 | pier MCP 401 challenge names only resource_metadata, so clients request the advertised scopes (write included) | 2026-11-06 | 609caa4 | [261106-kt1-pier-mcp-401-challenge-drops-the-fixed-r](./quick/261106-kt1-pier-mcp-401-challenge-drops-the-fixed-r/) |
| 261106-kt2 | deferred:purge, migrate* and queue:clear reuse the app's published database, so the in-server scheduler no longer fails with duplicate provider for *sql.DB | 2026-11-06 | 99abe88 | [261106-kt2-lagoon-deferred-purge-fails-in-the-sched](./quick/261106-kt2-lagoon-deferred-purge-fails-in-the-sched/) |
## Deferred Items

View File

@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 20
open_count: 26
waived_count: 0
fixed_count: 7
total_count: 27
last_updated: 2026-10-22T14:44:56.679Z
fixed_count: 9
total_count: 35
last_updated: 2026-11-04T19:19:01.124Z
---
# Broken Windows Ledger
@@ -42,6 +42,14 @@ last_updated: 2026-10-22T14:44:56.679Z
| 25 | 19.7 | deviation | modules/cabana | | 19.7-01: admin route inventories (phase09Routes, tokenRouteClasses, CSRF walk 35, phase10 want list, conformCases) extended for the two account contribution routes | open | | 2026-10-22T14:44:56.218Z | |
| 26 | 19.7 | deviation | modules/cabana | | 19.7-01: TestRecordActionPromptRun added on Postgres to prove Run receives the trimmed prompt value | open | | 2026-10-22T14:44:56.449Z | |
| 27 | 19.7 | unrun-verify | scripts/check-phase19.6.1.sh | | 19.7-01: check-phase19.6.1.sh --all stopped by the host (memory pressure) after the cabana and surf Postgres suites passed; re-run on the Docker host | open | | 2026-10-22T14:44:56.679Z | |
| 28 | 33 | deviation | modules/lagoon/validate.go | | 33-02: lagoon.Validate rejects the string and url rule tokens, so the Journal admin settings save answers 500 (pre-existing); the Headings and links switch is saved through the settings API/MCP until fixed | open | | 2026-11-04T09:39:12.905Z | |
| 29 | 33 | stub | contract/contract.go | | sm-sitemap-plugin: contract.ListingSettings HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile stay zero and golem15_sitemap_urls.images stays [] until 33-04 adds the HTML sitemap, IndexNow and image settings | fixed | | 2026-11-04T10:26:56.754Z | 2026-11-04T11:41:58.774Z |
| 30 | 33 | deviation | models/settings/fields.yaml | | sm-sitemap-plugin: the Additional addresses lastmod is a text field read as YYYY-MM-DD (server-checked, errors.lastmod) because the framework repeater accepts no datepicker column; swap to a datepicker once cabana supports it in repeaters | open | | 2026-11-04T10:26:56.940Z | |
| 31 | 33 | deviation | models/settings/fields.yaml | | sm-sitemap-plugin: News sources is a taglist with custom tags and no options (values listed by the news_sources partial, server-checked) because cabana taglist options are a fixed YAML list and form extensions take no taglist; switch to options from the article types and categories once the framework offers per-request taglist options | open | | 2026-11-04T11:42:13.158Z | |
| 32 | 33 | deviation | controllers/issues/config_filter.yaml | | sm-seo-plugin: the Site audit status filter cannot default to Open (cabana list filters have no default value), so the list shows ignored issues too, badged Ignored; GET /_seo/api/v1/issues and seo_list_issues default to status open; set the default once cabana filters accept one | open | | 2026-11-04T15:03:28.372Z | |
| 33 | 33 | deviation | controllers/issues/_issue_details.htm | | sm-seo-plugin: P7 shows Open the page and the Rich Results Test as labelled addresses, not links, because the partial allowlist keeps only same-origin hrefs; make them links once partials may carry an external link (new tab) | open | | 2026-11-04T15:03:28.619Z | |
| 34 | 33 | deviation | vue-demo-app/nuxt.config.ts | | 33-12: the demo serves Polish by cookie at one address (no_prefix), but the CMS names /pl/... alternates and sitemap addresses, which answer 404; i18n strategy decision before 33-14 applies the demo patches | open | | 2026-11-04T17:10:13.946Z | |
| 35 | 33 | todo | sm-sitemap-plugin/classes/rebuild.go | | 33-12: additional addresses are not de-duplicated against content records (migrated /, /blog, /faq listed twice on rome-like data); the demo DEPLOY step works around it | fixed | | 2026-11-04T17:10:14.144Z | 2026-11-04T19:19:01.124Z |
````json
[
@@ -395,6 +403,110 @@ last_updated: 2026-10-22T14:44:56.679Z
"recorded_at": "2026-10-22T14:44:56.679Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 28,
"kind": "deviation",
"phase": "33",
"file": "modules/lagoon/validate.go",
"line": null,
"description": "33-02: lagoon.Validate rejects the string and url rule tokens, so the Journal admin settings save answers 500 (pre-existing); the Headings and links switch is saved through the settings API/MCP until fixed",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T09:39:12.905Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 29,
"kind": "stub",
"phase": "33",
"file": "contract/contract.go",
"line": null,
"description": "sm-sitemap-plugin: contract.ListingSettings HTMLEnabled, HTMLPaths, HTMLPageSize and KeyFile stay zero and golem15_sitemap_urls.images stays [] until 33-04 adds the HTML sitemap, IndexNow and image settings",
"status": "fixed",
"reason": "",
"recorded_at": "2026-11-04T10:26:56.754Z",
"resolved_at": "2026-11-04T11:41:58.774Z",
"milestone": "v1.0"
},
{
"id": 30,
"kind": "deviation",
"phase": "33",
"file": "models/settings/fields.yaml",
"line": null,
"description": "sm-sitemap-plugin: the Additional addresses lastmod is a text field read as YYYY-MM-DD (server-checked, errors.lastmod) because the framework repeater accepts no datepicker column; swap to a datepicker once cabana supports it in repeaters",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T10:26:56.940Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 31,
"kind": "deviation",
"phase": "33",
"file": "models/settings/fields.yaml",
"line": null,
"description": "sm-sitemap-plugin: News sources is a taglist with custom tags and no options (values listed by the news_sources partial, server-checked) because cabana taglist options are a fixed YAML list and form extensions take no taglist; switch to options from the article types and categories once the framework offers per-request taglist options",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T11:42:13.158Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 32,
"kind": "deviation",
"phase": "33",
"file": "controllers/issues/config_filter.yaml",
"line": null,
"description": "sm-seo-plugin: the Site audit status filter cannot default to Open (cabana list filters have no default value), so the list shows ignored issues too, badged Ignored; GET /_seo/api/v1/issues and seo_list_issues default to status open; set the default once cabana filters accept one",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T15:03:28.372Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 33,
"kind": "deviation",
"phase": "33",
"file": "controllers/issues/_issue_details.htm",
"line": null,
"description": "sm-seo-plugin: P7 shows Open the page and the Rich Results Test as labelled addresses, not links, because the partial allowlist keeps only same-origin hrefs; make them links once partials may carry an external link (new tab)",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T15:03:28.619Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 34,
"kind": "deviation",
"phase": "33",
"file": "vue-demo-app/nuxt.config.ts",
"line": null,
"description": "33-12: the demo serves Polish by cookie at one address (no_prefix), but the CMS names /pl/... alternates and sitemap addresses, which answer 404; i18n strategy decision before 33-14 applies the demo patches",
"status": "open",
"reason": "",
"recorded_at": "2026-11-04T17:10:13.946Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 35,
"kind": "todo",
"phase": "33",
"file": "sm-sitemap-plugin/classes/rebuild.go",
"line": null,
"description": "33-12: additional addresses are not de-duplicated against content records (migrated /, /blog, /faq listed twice on rome-like data); the demo DEPLOY step works around it",
"status": "fixed",
"reason": "",
"recorded_at": "2026-11-04T17:10:14.144Z",
"resolved_at": "2026-11-04T19:19:01.124Z",
"milestone": "v1.0"
}
]
````

View File

@@ -1,5 +1,5 @@
---
status: awaiting_human_verify
status: resolved
trigger: "User plugin logout: auth cookies not invalidated at once; requests keep authenticating ~60 min until token expiry (todo 2026-10-22-user-logout-cookie-stays-valid)"
created: 2026-10-22T00:00:00Z
updated: 2026-10-22T00:00:00Z

View File

@@ -42,3 +42,7 @@ The console layer (`modules/bonfire`) and the new logging module (`modules/diary
- Removing the stack later means rewriting bonfire's widgets and diary's console handler; D-01 and D-02 are rated costly for that reason.
- Plugins never import charm modules directly, so a later change of styling library touches only `modules/bonfire` and `modules/diary`.
- A check in the plan's acceptance criteria (`go list` over every package's imports) keeps the import boundary honest.
## Addendum 2026-10-28: fang removed
`charm.land/fang/v2` was removed in quick 261028-rqd (console review finding IN-06). `fang.Execute` installs its own help function on the root just before `root.ExecuteContext`, so after `bonfire.Execute` the root's help function was no longer bonfire's list renderer, and cobra offers no per-root hook in between to restore it. bonfire had already switched off every fang feature except the error handler, so `bonfire.Execute` now runs `root.ExecuteContext` itself and renders errors with `RenderError` on the root's error stream. The only other thing fang did, enabling VT processing on Windows consoles, is done through `lipgloss.EnableLegacyWindowsANSI` on the output and error streams when they are files (a no-op off Windows). `go mod tidy` dropped fang and its exclusive transitive modules (mango, mango-cobra, mango-pflag, roff, x/exp/charmtone); no module was added. The list, help and error output stayed byte-identical (the goldens pass unchanged). The stack is now five charm modules plus colorprofile.

View File

@@ -0,0 +1,42 @@
# Decision: the site audit's rendered comparison uses chromedp, optionally
**Date:** 2026-10-28 (dependency approved 2026-11-04)
**Status:** Accepted
**Context:** Phase 33 (SEO output, audit and content, D-70), 33-CONTEXT.md "Rendered comparison (Jakub, 2026-10-28): optional chromedp", 33-RESEARCH.md Alternatives considered and Package Legitimacy Audit, plan 33-06 Task 4 (approval), plan 33-10 Task 4 (P33-55)
## Decision
The site audit's crawler (`sm-seo-plugin`, package `classes/crawl`) always checks the raw, server-rendered HTML. It compares a page's raw HTML with its JavaScript-rendered DOM only when an operator configures a Chrome or Chromium binary; without one every crawl records the comparison as skipped and says so (the crawl preview P6, `GET /_seo/api/v1/crawls/{id}` and the MCP tool `seo_get_crawl` answer `render {status: skipped}`).
- **`github.com/chromedp/chromedp` pinned at exactly v0.21.0.** Published 2026-10-11 (proxy.golang.org, origin `github.com/chromedp/chromedp` at tag `v0.21.0`, commit `7862adfd`), MIT. Approved by Jakub on 2026-11-04 (33-06 Task 4). v0.21.1 and v0.22.0 exist but were under two weeks old at approval and are deliberately not used. Its go.mod requires `github.com/chromedp/cdproto` v0.157.8 and `github.com/go-json-experiment/json`; nothing else.
- **Why this library.** A maintained pure-Go client of the Chrome DevTools Protocol: no cgo, no Node runtime, no bundled browser. It drives an operator-installed Chrome or Chromium binary (RESEARCH Alternatives considered: a raw-HTML-only crawl misses content that appears only after JavaScript; a Node headless browser would add a second runtime to a single-binary deployment).
- **What is compared** (D-70, P33-55): same-host links, an H1, or a substantial amount of text (at least 50 words and 20 % more) that appear only after JavaScript are reported as `content_js` with `details.check: rendered`. The raw HTML against the CMS record stays `details.check: cms` and runs on every crawl.
- **Chrome is optional and operator-installed.** Production servers have none by default. The executable path is the Go config key `golem15.seo.crawl_chrome_path` (environment-overridable like every key); `golem15.seo.crawl_render_max` (default 200) caps the pages rendered per crawl, in crawl order. An empty path skips the comparison; a relative path, or one that is not an executable regular file, records the comparison as failed with a note. The crawl itself never fails because of the renderer.
- **The path comes from config only, never from an admin setting or MCP.** Any holder of `golem15.seo.manage` can write the SEO settings and call the SEO tools; a writable executable path would let them make the server run an arbitrary binary (T-33-69).
- **Plugin-only.** Only `sm-seo-plugin` requires chromedp. The framework go.mod does not (D-90: the framework gets no SEO code), and `grep chromedp go.mod` in summercms.go stays empty.
## The guard (P33-55)
Headless Chrome executes untrusted page JavaScript on the server, so it is held to the crawler's `fetchguard` limits:
- **Resolver pinning.** Before Chrome starts, the site host and its `www` twin are resolved and every address is checked with `fetchguard.IsPrivateAddr`; a private or reserved address refuses to start the renderer (render status failed, the note names the address) unless `golem15.seo.crawl_trusted` is true. The checked addresses go into `--host-resolver-rules` as `MAP <host> <address>` lines (one address per host, IPv4 first) followed by a final `MAP * ~NOTFOUND`, so every other host resolves to nothing and Chrome cannot re-resolve the site host to another address. Chrome applies the first rule that matches a host, so the catch-all must come last: with a leading `MAP * ~NOTFOUND` the site host itself fails with `ERR_NAME_NOT_RESOLVED` (checked against Chromium during 33-10; P33-55 had it leading).
- **Request interception.** Every request is paused through the DevTools Fetch domain and continued only when it is `https` (`http` only in trusted mode) to the site host or its twin, or a `data:` or `blob:` URL; every other request fails with `BlockedByClient`. Third-party scripts, fonts, analytics and link-local metadata addresses are therefore never loaded.
- **No state.** Each crawl uses a fresh temporary profile (`os.MkdirTemp`) removed when the renderer closes; no cookies, Authorization header, HTTP credentials or extensions; downloads denied. The crawler never signs in.
- **Limits.** One tab renders at a time, 20 s per page, the DOM read with `OuterHTML` is capped at 5 MiB, and Chrome is closed when the crawl finishes, fails or is cancelled.
- **Sandbox.** chromedp's default allocator flags are used without additions that weaken Chrome's sandbox (no `--no-sandbox`).
## Rejected alternatives
- **Raw HTML only.** No dependency and no browser on the server, but content and links that appear only after JavaScript go unreported, which D-70 asks the audit to find.
- **`github.com/go-rod/rod`.** Also a pure-Go DevTools client, but it downloads a browser by itself by default and adds a larger API surface; chromedp's allocator takes an explicit executable path, which keeps the binary under operator control.
- **A Node headless browser (Puppeteer, Playwright).** A second runtime next to the single binary.
## Package legitimacy
The package-legitimacy seam cannot check Go modules, so chromedp was gated by the blocking human check of plan 33-06 Task 4 and approved by Jakub (v0.21.0) before any `go get`. v0.21.0 resolves through proxy.golang.org with the origin above.
## Consequences
- `sm-seo-plugin/go.mod` requires `github.com/chromedp/chromedp v0.21.0` (and, through it, `github.com/chromedp/cdproto`); applications that mount the plugin get it through the plugin, and chromedp is compiled in whether or not a Chrome path is configured.
- A site whose crawl should compare rendered pages installs Chrome or Chromium on the server and sets `golem15.seo.crawl_chrome_path`; nothing else changes.
- If the library ever needs replacing, only `classes/crawl/render_chrome.go` changes: the crawler talks to the `crawl.Renderer` seam.

View File

@@ -0,0 +1,50 @@
# Decision: WebP variants of attachment thumbnails through github.com/gen2brain/webp
**Date:** 2026-10-28 (dependency approved 2026-11-04)
**Status:** Accepted
**Context:** Phase 33 (SEO output, audit and content, D-85), 33-CONTEXT.md "Image formats (Jakub, 2026-10-28): WebP now, in Go", 33-RESEARCH.md Open Question 3 and Package Legitimacy Audit, plan 33-06 Task 4 (approval), plan 33-11 (P33-42, P33-43)
## Decision
Sites serve their featured images as `<picture>` with a WebP source and the original format as the `srcset` fallback. The WebP files are encoded in Go, once per thumbnail size, in a background job, never in the upload request. AVIF is deferred.
- **`github.com/gen2brain/webp` pinned at exactly v0.6.4.** Published 2026-07-01 (proxy.golang.org, origin `github.com/gen2brain/webp` at tag `v0.6.4`, commit `3dcf5268`), MIT (the bundled libwebp under its BSD licence, `lib/LICENSE.libwebp`). Approved by Jakub on 2026-11-04 (33-06 Task 4). Its go.mod requires `github.com/ebitengine/purego` v0.10.1 and nothing else.
- **Why this library.** `golang.org/x/image/webp`, which `attach` already uses, decodes only; the standard library has no WebP encoder. cgo bindings to libwebp would break the cgo-free build of the single binary. gen2brain/webp encodes in Go without cgo.
- **What is encoded.** Each thumbnail a caller asks for (`attach.File.ThumbKey` at a width, height and mode) gets one WebP variant next to it, at quality 80, under `attach.VariantKey(thumbKey, "webp")`. The original upload is never re-encoded, and images an editor inserts into a body keep their original file.
- **Where it runs.** Journal's conga job `golem15.journal.image_variants` is dispatched after a featured image upload commits, and `journal:image-variants --all` backfills existing images. A slow or failing encode never fails the upload.
## How it runs (checked in the v0.6.4 module source, 2026-11-04)
- **First choice: the system libwebp through purego.** At init the package opens `libwebp.so` and `libwebpdemux.so` with `purego.Dlopen` (no cgo). On Linux this only works in a dynamically linked binary.
- **Fallback: libwebp transpiled to Go.** When the libraries are missing (or with the build tag `nodynamic`), it uses libwebp compiled to WASM and then transpiled to plain Go source by wasm2go (`libwebp.go`, about 2.7 MB of generated Go). There is no WASM runtime at run time and no wazero; 33-CONTEXT's "WASM fallback" means this transpiled code. `webp.Dynamic()` reports which path is active.
- **Linking consequence.** purego's no-cgo runtime support makes a binary that imports the package dynamically linked, even with `CGO_ENABLED=0`: the probe binary needed the ELF interpreter `/lib64/ld-linux-x86-64.so.2` and `libc.so.6`, `libdl.so.2` and `libpthread.so.0`. It runs on glibc hosts such as the Debian servers. It does not run in a `scratch` image, a distroless static image or on musl (Alpine). Building with `-tags nodynamic` keeps the binary statically linked and uses only the transpiled code (checked: `statically linked`, `Dynamic()` reports "dynamic disabled").
- **Side effect.** The package registers a `webp` decoder with `image.RegisterFormat`, next to the one `golang.org/x/image/webp` registers. Both decode WebP, so `image.Decode` keeps working whichever one it picks.
## Benchmark
- **33-CONTEXT, 2026-10-28.** A 1200 px variant of a 2592×1754 photo: WebP 78 KB against JPEG q80 at 133 KB. Encoding took about 90 ms with the system libwebp and about 510 ms in the fallback. AVIF came out only about 6 % smaller than WebP, at 1.3× to 20× the CPU.
- **Probe of v0.6.4 on 2026-11-04** (this workstation, a synthetic 1200×812 gradient, quality 80): about 17 ms through the system libwebp and about 90 ms in the transpiled fallback. WebP was 29.8 KB against 58.4 KB for JPEG q80. The two runs used different images and machines, so compare the paths with each other, not the numbers across runs.
## Where the code lives (P33-42)
- **Framework, generic media.** `modules/lagoon/attach/webp` (package `webp`): `Encode(w, img)` at quality 80 and `Variant(ctx, bucket, f, w, h, mode)`. `Variant` writes the variant next to the thumbnail, returns early when it exists, and deletes the key when an encode or close fails. In `attach` itself there is one generic helper, `attach.VariantKey(thumbKey, format)`.
- **Why it is not SEO code.** A WebP variant of a thumbnail is a media format. It has nothing to do with SEO, and the framework gets no SEO wording (D-90). The addition is additive at patch level (D-106).
- **Only binaries that import it link it.** `attach` does not import the sub-package, so a binary links gen2brain/webp, purego and the linking consequence above only when it imports `modules/lagoon/attach/webp`. In practice that means through sm-journal-plugin.
- **The job lives in Journal.** Only plugins register conga jobs, and Journal owns the featured images and their variants table (`golem15_journal_image_variants`, P33-43).
## Rejected alternatives
- **AVIF through `github.com/gen2brain/avif`.** About 6 % smaller than WebP at 1.3× to 20× the CPU. Deferred.
- **A Nuxt image pipeline (sharp).** New npm dependencies with a native module, and the encoding would move into the frontend server.
- **cgo bindings to libwebp.** They break the cgo-free cross-compiled builds the applications use.
- **Widths only, no new format.** This needs no dependency, but D-85 asks for a modern format.
## Package legitimacy
The package-legitimacy seam cannot check Go modules, so gen2brain/webp was gated by the blocking human check of plan 33-06 Task 4 and approved by Jakub at v0.6.4 before any `go get`. v0.6.4 resolves through proxy.golang.org with the origin above (`h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=`).
## Consequences
- The framework go.mod requires `github.com/gen2brain/webp v0.6.4` (and, through it, `github.com/ebitengine/purego v0.10.1`). Only packages under `modules/lagoon/attach/webp` import it.
- An application that mounts sm-journal-plugin links the encoder. Its binary is dynamically linked against glibc unless it is built with `-tags nodynamic`. Installing libwebp on the server makes encoding about five times faster, and nothing else changes. The package opens the unversioned names `libwebp.so` and `libwebpdemux.so`, which Debian ships in `libwebp-dev`.
- If the library ever needs replacing, only `modules/lagoon/attach/webp/webp.go` changes. Callers use `Encode` and `Variant`.

View File

@@ -0,0 +1,44 @@
# Decision: Plugin marketplace on summercms.io and `summer plugin:install`
**Date:** 2026-10-29
**Status:** Accepted (exploration)
**Context:** `/msd-explore` session on summercms.io/marketplace, modelled on octobercms.com/marketplace
## Scope of v1
- The marketplace lists the public Golem15 plugins (`sm-*-plugin` repos that stay public). No paid plugins and no developer registration: Golem15 publishes every listing.
- Its job is browsing plugins and getting the install command, nothing more.
## Decisions
### Listing data: facts in the repo, presentation on summercms.io
- Each plugin repo carries a `plugin.yaml` manifest next to `plugin.go`: plugin ID, module path, name, description, required framework version, required plugins. The README and git tags (`vX.Y.Z`) complete the facts.
- summercms.io ingests the manifest, README and tags when a plugin is tagged. The hook is the existing `scripts/changelog-post.py publish … --repo ../sm-<name>-plugin` step or a webhook.
- Presentation fields (category, featured flag, screenshots, icon, sort order) are edited in the summercms.io admin, in the `golem15.summercms` site plugin.
- Reason: install-critical data (module path, versions, requirements) cannot drift from the repo, while marketing details change without a plugin release. A repo manifest also leaves room for third-party submissions later (submit a repo URL).
### `summer plugin:install`
The form of the argument decides where the plugin comes from:
| Argument | Lookup | Default install mode | Override |
|----------|--------|----------------------|----------|
| Plugin ID, e.g. `golem15.journal` | summercms.io registry API: module path, versions, requirements | Go module (`require` in `go.mod`, `go mod download`) | `--source`: git submodule checkout at `plugins/<vendor>/<name>`, as `plugin:add` does today |
| Git URL, e.g. `git@github.com:acme/sm-servers-plugin.git` | None. summercms.io is never contacted. ID and requirements come from the repo's `plugin.yaml`, module path from its `go.mod` | Git submodule checkout, which reuses the SSH access the user already has | `--module` (see the open research question on private modules) |
- Git URLs cover closed or private plugins from repositories the user has access to.
- Both modes end with the same registration that `plugin:add` does (`summer.yaml`, `go.mod`, `go.work` where applicable), then `summer build`.
- Precondition: public plugin repos on git.golem15.com fetch anonymously (HTTPS clone and `?go-get=1` meta). Confirmed as the intent for public plugins on 2026-10-29.
### Admin "Plugins" screen (read-only)
- **Installed** tab: the activated plugins from `backpack.App`, with version and description, and updates available compared against the registry.
- **Marketplace** tab: searches the summercms.io registry and shows `summer plugin:install <id>` with a copy button. There is no install button.
- The running binary never installs plugins. Plugins are compiled in; runtime loading stays rejected (`why-go-not-scala.md`, `.planning/research/FEATURES.md` anti-features). Queued installs and dev-mode live installs were considered and dropped as unnecessary for v1.
## Out of scope for v1
- Paid plugins, licensing, developer accounts and submissions.
- Themes in the marketplace.
- Installing from the admin panel.

View File

@@ -0,0 +1,100 @@
---
phase: "11.2"
slug: "ready-to-share-summercms-io-website-and-newsletter-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-11-05"
---
# Phase 11.2 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. The newsletter plugin was not built in this phase (moved to backlog 999.1); no threat in the register concerns it. Evidence roots: FW = `summercms.go`, APP = `../sm-summercmsio-app`, PLUG = `APP/plugins/golem15/summercms`, SITE = `APP/vue-summercmsio-app`. Verified against the current tree; later phases (changelog, marketplace, VPN admin) changed the app, and replaced prongs are noted. T-11.2-SC is declared in all three plans (19 rows, 17 IDs).
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| npm registry → build machine | third-party packages run during install and generate | tarballs, git deps, install scripts (high) |
| en.json / terminal.json → rendered HTML | repository copy becomes public HTML | i18n strings, terminal commands (medium) |
| page → visitor clipboard | the Copy button writes text visitors paste into a shell | six shell commands (low) |
| page → third-party origins | any runtime request elsewhere leaks visitor data | IP, Referer (medium) |
| internet → nginx (rome) | untrusted HTTP; TLS terminates here | all public requests (high) |
| nginx → summercms-io on 127.0.0.1:8095 | proxied requests filtered by path and method | path, method, X-Forwarded-For (medium) |
| request path → embedded file map | an untrusted path selects a file and may redirect | URL path, Location (high) |
| site.yaml / CLI flags → docs header HTML | configured URL written into every docs page | site_url, site_label (medium) |
| operator machine → rome | binary and config upload; secrets stay on the server | binary, config (medium) |
| summercms.go → module proxy | a pushed tag becomes immutable public state | release tag (medium) |
| test results → phase sign-off / env gates → integration tests | a gate misreading skips passes untested behaviour | test events, gate variables (medium) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-11.2-01 | Tampering (XSS) | components rendering en.json | medium | mitigate | no v-html/innerHTML under SITE/app/; inline code via `<i18n-t>` | closed |
| T-11.2-02 | Information disclosure (privacy) | font loading | medium | mitigate | `@nuxt/fonts` build-time download (SITE/nuxt.config.ts:40,64-71); SITE/tests/output.test.ts:218-231 asserts no Google font hosts; built output clean | closed |
| T-11.2-03 | Tampering | Copy button payload | low | accept | AR-11.2-01 | closed |
| T-11.2-04 | Information disclosure | PLUG static.go path resolution | high | mitigate | `path.Clean` + dot-segment 404 + preloaded map only, dotfiles never loaded (static.go:131-183); smoke.sh:230 and static_test.go:155-156 assert `/docs/.summer-docs` 404 | closed |
| T-11.2-05 | Spoofing (open redirect) | /docs and extension-less redirects | medium | mitigate | constant `/docs/` redirect (plugin.go:86); redirect only to an existing mapped name (static.go:178-182); TestStaticRedirectLocations covers `//evil`, `../`, backslash | closed |
| T-11.2-06 | Tampering (MIME sniffing) | static.go headers | low | mitigate | own content-type table first (static.go:30-60); `nosniff` on every file incl. 404s and scripts | closed |
| T-11.2-07 | Elevation of privilege | admin exposure | high | mitigate | public nginx `/backend` → 404 (summercms.io.conf:74-76), binary on loopback, check-deploy.sh:129-130 guards the block; admin only on the VPN vhost (10.8.3.0/24). Replaced since: plugin now has admin controllers (Phase 34) and smoke.sh:225 expects 200 on the binary; the internet-facing control holds | closed |
| T-11.2-08 | Information disclosure | DB password and app key | high | mitigate | empty secrets in committed config; `.env` gitignored, only `*.example` tracked; `.env` root:summercms 0640, never rsynced (DEPLOY.md:195,289) | closed |
| T-11.2-09 | Tampering (XSS) | docs header site_url | medium | mitigate | `checkSiteURL` on yaml key and flag (internal/docsite/load.go:125,145-161,289); `html/template` with plain string; load_test.go rejects `javascript:` and `//` | closed |
| T-11.2-10 | Tampering (clickjacking) | public pages | low | accept | AR-11.2-02 | closed |
| T-11.2-11 | Information disclosure (transport) | nginx | high | mitigate | 80→443 301 except ACME, www→apex, certbot certs, HSTS (summercms.io.conf:20-63); binary binds 127.0.0.1:8095 | closed |
| T-11.2-12 | Denial of service | nginx and binary | medium | mitigate | `limit_except GET HEAD` on `/` (:176-179); site routes GET-only (routes_test.go, smoke POST → 405); preloaded static files; supervisor restart | closed |
| T-11.2-13 | Tampering / Repudiation | v0.1.0 tag | medium | mitigate | blocking-human checkpoint (11.2-02-PLAN.md:318) resolved as defer-tag; no v0.1.0 exists; all tags annotated; CLAUDE.md requires go-ahead | closed |
| T-11.2-14 | Elevation of privilege | runtime account | medium | mitigate | nologin system user, dedicated Postgres role and DB, root-owned install root with only `storage/` writable (DEPLOY.md:173-182); supervisor `user=summercms` | closed |
| T-11.2-15 | Repudiation (false green) | scripts/check-phase11.2.sh | medium | mitigate | detector refuses build failure, no-tests, skip, fail, zero tests, missing named tests (:58-120); unknown flag exits 2 | closed |
| T-11.2-16 | Tampering (silent skip) | build-gated tests and terminal check | medium | mitigate | gate sets `SUMMERCMS_REQUIRE_BUILD=1` and `SUMMERCMS_TERMINAL_CHECK=1`; links_test.go `requireBuild` fails instead of skipping | closed |
| T-11.2-SC (01) | Tampering | pnpm installs | high | mitigate | exact pins, committed lockfile (git deps locked to commits), build allow-list of three packages, `--frozen-lockfile` (APP/scripts/build.sh:121) | closed |
| T-11.2-SC (02) | Tampering | dependency installs | high | mitigate | APP go.mod first-party only; PLUG direct requires (Phase 34) all already in FW go.mod at the same versions; go.sum committed; dirty-tree builds refused (build.sh:80-90) | closed |
| T-11.2-SC (03) | Tampering | dependency installs | low | accept | AR-11.2-03 | 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-11.2-01 | T-11.2-03 | Payload is the bundled terminal.json, identical to the visible lines; the page only writes to the clipboard and never reads it. | 11.2-01 PLAN | 2026-11-05 |
| AR-11.2-02 | T-11.2-10 | Static marketing and docs pages with no state-changing actions; the admin CSP is deliberately not reused because it blocks Nuxt's inline scripts (D-07). | 11.2-02 PLAN | 2026-11-05 |
| AR-11.2-03 | T-11.2-SC (03) | Tests use the stdlib and the existing node:test runner; no package is added; `pnpm install --frozen-lockfile` reuses 11.2-01's lockfile. | 11.2-03 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Notes (non-blocking)
- **Attack surface added after 11.2** (covered by later phases' registers): admin controllers on the binary behind the VPN vhost; `APP/DEPLOY.md:190` ("nginx refuses /backend, so nothing writes runtime config overrides") is stale now that the admin is reachable over the VPN — writes would still fail on the root-owned `config/`. New write routes: `/_journal/api/v1/` (token + throttle) and POST `/api/registry/v1/ingest` (T-34-15/16). Marketplace readme `v-html` is covered by T-34-13.
- **Open review info items:** IN-01..IN-08 (11.2-REVIEW-DISPOSITION.md), none security-relevant beyond IN-07 (links_test.go follows redirects).
- **UAT:** 11.2-UAT.md is still `testing` with tests 1-3 pending; the cutover test expects v0.1.0, which was never created (releases started at v0.1.1). Superseded rather than failed.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 19 rows (17 IDs) | 19 | 0 | msd-security-auditor (ASVS L1, block_on high; repo contents only, no live-host checks) |
---
## 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-11-05

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

@@ -98,7 +98,7 @@ Post-plan attack surface from the quick tasks, mapped to existing entries: `pact
|---------|------------|-----------|-------------|------|
| AR-18.1-01 | T-18.1-04 | Reorder POST runs under the controller's protect; taglist adds no route and rides the existing saves | plan 18.1-01 (verified by audit) | 2026-10-16 |
| AR-18.1-02 | T-18.1-40 | product_id and plan_code are Stripe ids and a plan code, not PII; TestJobArgsMinimal pins the key list and refuses PII | plan 18.1-07 (verified by audit) | 2026-10-16 |
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) | user | 2026-10-16 |
| AR-18.1-03 | T-18.1-22 | Toolbar admin actions (sync_all_to_stripe) are not audit-logged with the admin id on success; bulk actions are. The action needs manage_plans and pgsub logs each synced plan. Follow-up todo: admin action audit log (plugin or cabana extension) Resolved 2026-10-28 by quick 261028-rqf (61d1043): toolbar and field action runs are logged with admin_id on success. | user | 2026-10-16 |
| AR-18.1-04 | T-18.1-43 | A Checkout Session already paid but whose webhook is not yet processed is complete, not open, so it is not expired; a second checkout in that window can bill twice. Detected by warnDuplicateOpen (Error log). Replaces the old 10-minute idempotency key, which covered only identical parameters | user (UAT row 5, quick 261016-myi) | 2026-10-16 |
| AR-18.1-05 | T-18.1-39 | Session advisory locks hold a pooled connection; a capped MaxOpenConns or a re-entrant listener could starve the pool (review IN-02, open) | audit residual | 2026-10-16 |
| AR-18.1-06 | T-18.1-24 | The customer API throttle keys on client IP in practice (JWT check runs inside the handler); no 429 test for this bucket | audit residual | 2026-10-16 |

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,142 @@
---
phase: "19.3"
slug: "framework-transport-and-infrastructure-for-wavepath"
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-11-05"
---
# Phase 19.3 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Framework paths are relative to the repo root (HEAD `7b8d205`); `seo/` = `../vue-seo-layer`. The shared Nuxt purge route moved after the phase from the faq, menu and journal layers into vue-seo-layer, which all three now extend; T-23, T-29, T-37 and T-43 were verified there. T-19.3-SC is declared once per plan (6 rows, one ID).
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| application to Redis | network hop carrying credentials in `redis.url` and shared counters other instances trust | Redis URL, cache entries, rate-limit counters (high) |
| request data to cache and limiter keys | keys can derive from client IPs, user ids or request values | key material (medium) |
| config and environment to boot | CORS, cookie, Turnstile, api-docs, `redis.url`, driver keys, `public_url` validated or reported before serving | config values, secrets (high) |
| browser on a sibling subdomain / SPA origin to the API | credentialed cross-origin requests and the token cookie | session cookie, API answers (high) |
| request to Turnstile middleware | untrusted token and body; siteverify crosses to Cloudflare | Turnstile token, secret (high) |
| jsDelivr CDN to the api-docs page | third-party scripts executed in the app's origin | JS (medium) |
| client upload to attach.Store | untrusted bytes and file name | file content, name (high) |
| bucket or CDN to the browser | stored files served from the app's or a CDN's origin | files (medium) |
| AWS environment to s3blob | credentials outside summer config | AWS credentials (medium) |
| summer.yaml to go build | manifest values become command arguments | build tags (medium) |
| Go app to the site's Nitro purge route | bearer-token POST over loopback or https after admin saves | purge token (high) |
| network to the Nitro purge route | anyone who can reach the site can call the route | purge requests (medium) |
| plugin hooks to the save transaction | hook errors roll back admin writes | hook results (low) |
| Go module proxy to the build | a new test-only module enters go.sum | modules (medium) |
| test harnesses to the network | httptest, miniredis and testcontainers only | test traffic (low) |
| local checkouts to origin | tags and pushes every consumer reads | tags (medium) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.3-01 | Information disclosure | cooler.OpenRedis, Install errors/logs | high | mitigate | cooler/redis.go:15,30-46 fixed error naming `redis.url`, parse error dropped, Addr-only endpoint log; TestOpenRedisRedactsSmoke | closed |
| T-19.3-02 | Denial of service | RateLimitStore during outage | medium | mitigate | ratelimit.go:63-73 attempt timeout + fallback; 500 ms (cooler.go:56); once/min warner (:410-446); memory fallback (surf/serve.go:237) | closed |
| T-19.3-03 | Tampering | Redis key space | medium | mitigate | `<prefix>cache:` / `<prefix>rl:` namespaces; SCAN with escaped glob + UNLINK, no FLUSHDB (rediscache.go:121-146). Residual IN-01 | closed |
| T-19.3-04 | Denial of service | memory cache growth | medium | mitigate | memory.go:40-45 `MaximumSize`, <1 refused; cooler.go:51,304-312 | closed |
| T-19.3-05 | Information disclosure | route:list/tests dialing Redis | low | mitigate | serve.go:83 only `installCooler` caller; router.go:556-566 lookup only; store_lookup_test.go:60 | closed |
| T-19.3-06 | Information disclosure | CORS credentials + `*` | high | mitigate | surf/cors.go:50-51 enforced via LoadCORSConfig and LoadCORSProfiles; TestCORSCredentialsWildcardRefusedSmoke | closed |
| T-19.3-07 | Tampering | shared-cache CORS answers | medium | mitigate | cors.go:240-281 Vary added before the allow check, preflight; :488-503 exact-origin echo | closed |
| T-19.3-08 | Spoofing | token cookie attributes | high | mitigate | bouncer/cookie.go:85-116 Domain validity, production Secure, None requires Secure; HttpOnly hard-coded (:154,:172) | closed |
| T-19.3-09 | Information disclosure | cookie Domain scope | medium | mitigate | cookie.go:82-90 host-only default, Domain opt-in; docs/services/authentication.md:95 | closed |
| T-19.3-10 | Tampering | CSRF on refresh/logout | low | accept | AR-19.3-01. Residual IN-08 | closed |
| T-19.3-11 | Spoofing | Turnstile with no secret | high | mitigate | bouncer/turnstile.go:111-119 production + no secret → 503, success required; production flag from config (router.go:732-733). Residual IN-11 | closed |
| T-19.3-12 | Denial of service | slow siteverify | medium | mitigate | turnstile.go 5 s client + ctx, 4 KiB LimitReader | closed |
| T-19.3-13 | Tampering | body consumed by Turnstile | medium | mitigate | turnstile.go:264-266 read once, restored; body limit wraps route middleware (router.go:513-515); TestTurnstileMiddlewareSmoke. Residual IN-05 | closed |
| T-19.3-14 | Information disclosure | Turnstile secret/token in logs | high | mitigate | secret only in form body; url.Error unwrapped; log carries error text only (turnstile.go:120-241); TestTurnstileNeverLogsSecret | closed |
| T-19.3-15 | Tampering | swagger-ui on jsDelivr | medium | mitigate | surf/apidocs.go:32-66 pinned 5.33.1 with sha384 SRI + crossorigin; nonce-only CSP (:208) | closed |
| T-19.3-16 | Information disclosure | /api-docs surface | low | accept | AR-19.3-02 | closed |
| T-19.3-17 | Tampering (XSS) | api_docs title/spec URL | medium | mitigate | apidocs.go `html/template` contextual escaping (:10,:55-81) | closed |
| T-19.3-18 | Tampering / EoP | stored XSS via disk name | high | mitigate | lagoon/attach/diskext.go:39-77, store.go:170 `DiskExtension(sniffed, ext)`; inline.go:38-44 attachment disposition; storage.md:110-135; TestDiskExtensionTable. Residual IN-07 | closed |
| T-19.3-19 | Information disclosure | public_url exposing files | medium | mitigate | 22-hex random names (store.go:294-300); boot Warn (bucket.go:157-161); storage.md:94 | closed |
| T-19.3-20 | Tampering | build.tags reaching go build | medium | mitigate | internal/build/manifest.go:121-172 validation; build.go:58-78 `exec.CommandContext`, single `-tags=` arg, no shell | closed |
| T-19.3-21 | Repudiation / misconfig | s3:// in an untagged binary | medium | mitigate | attach/bucket.go:106-137 refusal; s3_on.go/s3_off.go; build prints `build tags:` | closed |
| T-19.3-22 | Information disclosure | AWS credentials | medium | mitigate | storage.md:78 no keys in bucket_url/config; no credential keys in attach | closed |
| T-19.3-23 | DoS / Spoofing | shared Nitro purge route | medium | mitigate | seo/server/lib/siteCachePurge.ts:13,54-60 ≥32-char token else 404, proxied → 404, sha256 + timingSafeEqual; purge.post.ts:32-48; route `cache: false` | closed |
| T-19.3-24 | Information disclosure | purge token in Go logs/errors | high | mitigate | cooler/sitecache.go errors/logs name keys, kind, slug, status, host+path only; assertNoToken in sitecache_test.go | closed |
| T-19.3-25 | Information disclosure | token in clear on the network | high | mitigate | sitecache.go:97-132 user info refused, http only to loopback | closed |
| T-19.3-26 | Denial of service | purge breaking saves | medium | mitigate | `WithoutCancel` + 2 s, `lagoon.AfterCommit`, errors logged only (sitecache.go:159,302-309); faq/menu 500-receiver tests | closed |
| T-19.3-27 | Tampering | purge before commit | low | mitigate | AfterCommit (sitecache.go:302-309); `cabana.TxFromContext` in faq, menu, journal site_cache.go | closed |
| T-19.3-28 | Tampering | ReorderAfterSave failing | low | accept | AR-19.3-03 | closed |
| T-19.3-29 | Tampering | three route copies drifting | low | mitigate | superseded: single copy in vue-seo-layer, none left in faq/menu/journal; sha256 pins seo/tests/sitePurge.test.ts:26-39 | closed |
| T-19.3-30 | Repudiation | gate passing on skipped tests | medium | mitigate | scripts/check-phase19.3.sh:195-285 fails on skip/zero/missing; self-test (:357+). See "Gate out of date" | closed |
| T-19.3-31 | Tampering | unverified release | medium | mitigate | 19.3-05 checkpoint; v0.1.27 unmoved on origin, ancestor of v0.2.0 and origin/master | closed |
| T-19.3-32 | Information disclosure | secret-in-logs tests | low | mitigate | TestTurnstileNeverLogsSecret, TestOpenRedisRedactsSmoke, assertNoToken | closed |
| T-19.3-33 | Tampering | profiled-route refusals missing CORS/Vary | medium | mitigate | surf/router.go:462-530 profile wraps recovery/body limit/locale, :905-918 Vary merge; TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary | closed |
| T-19.3-34 | Information disclosure | credentialed wildcard/unanchored origins | high | mitigate | cors.go:61-160 anchoring and wildcard host-label rules with probes, per profile; TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch. Residual: multi-label public suffix (below) | closed |
| T-19.3-35 | Information disclosure | token forwarded on redirect | high | mitigate | sitecache.go:109-115 `CheckRedirect` → `ErrUseLastResponse`; TestSiteCachePurgeNoRedirect | closed |
| T-19.3-36 | Denial of service | synchronous per-row purges | medium | mitigate | single coalescing worker, Wait, Close drains (sitecache.go:212-277; cooler.go:123-131); async tests | closed |
| T-19.3-37 | Spoofing | allowProxied reachable from the network | medium | mitigate | siteCachePurge.ts:55-59 token check first, `allowProxied !== true` → 404; default false (seo/nuxt.config.ts:58-71); docs/services/caching.md:215 | closed |
| T-19.3-38 | Information disclosure | inferred insecure cookie in production | high | mitigate | cookie.go:107-112 error names `auth.cookie.secure` and `app.url`; TestLoadCookieConfigProductionInferredSecure | closed |
| T-19.3-39 | Information disclosure | zero CookieConfig | medium | mitigate | cookie.go:273-287 zero → Secure, SameSite Lax, used by both handlers; TestZeroCookieConfigDefaults | closed |
| T-19.3-40 | Elevation of privilege | api-docs CSP with host source | medium | mitigate | apidocs.go:65-67,208 nonce-only `script-src`; TestAPIDocsCSPNoHostSource | closed |
| T-19.3-41 | Information disclosure | protected files in a public_url bucket | high | mitigate | attach/store.go:38-42,139-140 refused before body read; boot warning; every bucket via attach.OpenBucket; TestStoreProtectedNeedsPrivateBucket | closed |
| T-19.3-42 | Spoofing | turnstile routes without a secret, unnoticed | medium | mitigate | router.go:710,739-758 one Warn with route count and production flag, never the secret; TestTurnstileEmptySecretWarnsAtBoot. Residual IN-11 | closed |
| T-19.3-43 | Tampering | layer copies drifting | low | mitigate | superseded, as T-19.3-29 | closed |
| T-19.3-44 | Tampering | unverified release / moved tag | medium | mitigate | 19.3-06 checkpoint; v0.2.0 contains 0c8109c and v0.1.27, on origin/master; v0.1.27 unmoved | closed |
| T-19.3-SC (01) | Tampering | go-redis, otter installs | high | mitigate | decision note 2026-10-15-cooler-go-redis-otter.md precedes the go.mod commit; pinned v9.23.0 / v2.3.0 | closed |
| T-19.3-SC (02) | Tampering | installs | high | mitigate | go.mod/go.sum unchanged across the plan; swagger-ui via SRI-pinned CDN | closed |
| T-19.3-SC (03) | Tampering | s3blob / AWS SDK | high | mitigate | gocloud.dev v0.46.0; s3blob only behind `//go:build summercms_s3`; note precedes the go.mod commit | closed |
| T-19.3-SC (04) | Tampering | installs | high | mitigate | framework go.mod unchanged; layer package.json version bumps only; plugin go.mod bumps add only modules the framework already pins | closed |
| T-19.3-SC (05) | Tampering | miniredis install | high | mitigate | human approval recorded (19.3-05-SUMMARY:121-129); pinned v2.40.0; test-only imports | closed |
| T-19.3-SC (06) | Tampering | installs | high | mitigate | go.mod/go.sum unchanged across the plan; layer commits bump versions only | 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-19.3-01 | T-19.3-10 | Refresh only rotates a cookie (the answer is unreadable cross-origin unless CORS allows that origin); logout CSRF is a forced logout; routes are POST-only and the docs advise JSON or a custom header (authentication.md:97). | 19.3-02 PLAN | 2026-11-05 |
| AR-19.3-02 | T-19.3-16 | /api-docs is off by default; the spec is the app's hand-written public surface (D-16); both routes are public by design (D-17). | 19.3-02 PLAN | 2026-11-05 |
| AR-19.3-03 | T-19.3-28 | The hook runs inside the reorder transaction by design so its failure rolls the reorder back, as FormAfter hooks do; purge hooks never return errors. | 19.3-04 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Unregistered Flags and Residual Risk (non-blocking)
1. **Purge token was published in page payloads (fixed after the phase).** The phase's layer versions read the token from `NUXT_SITE_CACHE_PURGE_TOKEN`; nuxt-site-config copies every `NUXT_SITE_*` variable into the public site config. Fixed: the token is now `cachePurge.token` / `NUXT_CACHE_PURGE_TOKEN`, seo/server/plugins/site-cache-purge.ts strips the old keys, and docs/services/caching.md:207 says to rotate. **Action:** confirm every deployment that set the old variable has rotated its token.
2. **Credentialed wildcard on a multi-label public suffix passes boot** (VERIFICATION advisory 1, next to T-19.3-34). `https://*.co.uk` is accepted: `wildcardHostLabel` accepts any non-empty remainder and the probes only cover `.invalid` and `.com`.
3. **Second purge route.** The deprecated `vue-journal-layer/server/api/__journal__/cache/purge.post.ts` predates this phase, still ships, and is not covered by this register.
4. Open review info items from 19.3-REVIEW-DISPOSITION: IN-01 (T-03), IN-05 (T-13), IN-07 (T-18), IN-08 (T-10), IN-11 (T-11/T-42: no log when a published verifier replaces a configured secret).
### Gate out of date
The layers stage of `scripts/check-phase19.3.sh` (lines 175-177, 524) still `cmp`s the purge files inside the faq, menu and journal layers, which moved to vue-seo-layer; that stage would now fail. No threat reopens; T-29/T-43 evidence now comes from vue-seo-layer.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 50 rows (45 IDs) | 50 | 0 | msd-security-auditor (ASVS L1, block_on high; `go test -short` on cooler, bouncer, surf, lagoon/attach, internal/build: 28 named tests pass, 4 Docker-backed tests not run) |
---
## 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
- [ ] Old `NUXT_SITE_CACHE_PURGE_TOKEN` values rotated on every deployment that set them
**Approval:** verified 2026-11-05 (ASVS L1)

View File

@@ -0,0 +1,137 @@
---
phase: "19.4.1"
slug: "early-public-demo-on-demo-summercms-io"
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-11-05"
---
# Phase 19.4.1 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Static audit of repository contents (no SSH, no production requests, tests not re-run; test results from the phase gate log and VERIFICATION). Audited: sm-demo-app da03c06 (after Phases 20 and 33), vue-demo-app fe5d38d (the submodule; the sibling `../vue-demo-app` checkout is stale), framework v0.6.1 and the plugin tags go.mod requires. Roots: DEMO = `../sm-demo-app`, VUE = `DEMO/vue-demo-app`, PLG = `DEMO/plugins/golem15`, FW = `summercms.go`.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| ROADMAP.md → client-facing deck | internal phase names include client applications | phase titles and summaries (high) |
| local .env → demo:seed | visitor credentials enter the database | visitor email and password (high) |
| local demo pages → committed screenshots | captures could show secrets or personal data | OIDC and admin PNGs (medium) |
| seeded deck HTML → public deck API | rendered on a public origin | slide HTML (low) |
| internet → nginx (demo.summercms.io) | only allow-listed prefixes reach Go; includes the public login-required `/smbackend` admin (user decision 2026-10-20) | all HTTP traffic, admin login (high) |
| nginx → Go and Nuxt on loopback | trusted proxy 127.0.0.1/32 sets client IP headers | X-Forwarded-For, X-Real-IP (medium) |
| build machine → module proxies and git.golem15.com | tags become immutable inputs for every consumer | plugin tags, go.sum (medium) |
| build machine → rome over SSH | secrets generated on rome or piped, never logged | env values, binaries (high) |
| Stripe → webhook | signed callbacks from the internet | events, Stripe-Signature (high) |
| browser → `/_user/api/v1/register` and `/login` | anonymous, automatable sign-ups and credential attempts | credentials, Turnstile token (high) |
| browser → `/_feedback/api/v1/{key}/submit` | anonymous free text with a public key | name, email, message (medium) |
| Go → Cloudflare siteverify and G15Office | outbound calls carrying secrets | Turnstile secret, G15Office token (high) |
| operator → demo:reset | destructive command on production data | `--force` (high) |
| browser locale cookie → SSR | user-controlled input selecting content | locale value (low) |
| registered user → `/_demo/api/v1/presentations` | untrusted HTML, markdown and settings stored | deck JSON (high) |
| browser → `/api/realtime/token` and `/ws` | authenticated users obtain realtime credentials | JWT, Centrifugo token (high) |
| Centrifugo → `/api/realtime/subscribe` | server-to-server over loopback | X-Centrifugo-Secret, user, channel (high) |
| Go → Centrifugo HTTP API | publishes slide positions | API key, positions (medium) |
| anonymous visitor → `/_demo/api/v1/quizzes` | public read of quiz metadata | slug, title, description, count (medium) |
| test harness → Docker / test fixtures → repositories | throwaway database, test-only secrets | test data (low) |
| check-live.sh → production origin | read-only probes | GETs plus one refused POST (low) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.4.1-01 | Information disclosure | roadmap deck builder | high | mitigate | denylist + case-insensitive `leaks` (DEMO/plugins/demo/roadmap/names.go:8-26); leak check after curated overrides, leaking phases left out, final per-slide guard (deck.go:56-60,143-146); `TestRoadmapNoClientNames` | closed |
| T-19.4.1-02 | Information disclosure | visitor credentials | high | mitigate | `golem15.demo.visitor.*` only, ≥8 chars, never printed (console/seed.go:101-141); empty defaults; dev.sh umask 077, prints key name only; `TestSeedVisitorFromConfig` | closed |
| T-19.4.1-03 | Information disclosure | OIDC screenshots | medium | mitigate | capture-oidc.cjs uses the example client only, never opens the one-time secret screen; user reviewed the images (01 Task 4) | closed |
| T-19.4.1-04 | Tampering | deck HTML | low | accept | AR-19.4.1-01 | closed |
| T-19.4.1-05 | Denial of service | repeated seeding | low | mitigate | old image rows deleted in-tx after import, blobs after commit (console/roadmap_deck.go:109,136-155); `TestSeedRoadmapUpsert` | closed |
| T-19.4.1-06 | Elevation of privilege | admin exposure | high | mitigate | **Re-scoped by the user's decision of 2026-10-20** (the plan's "SSH tunnel only" is superseded): `/backend` → 404 (nginx:78-80), `/smbackend` login-required; `login.max_attempts: 5 / 1 min`; ClientIP takes the rightmost untrusted XFF hop with the Cloudflare real-IP snippet; `X-Robots-Tag noindex`; `TestAdminLoginThrottle`; check-deploy.sh:245-259, check-live.sh:140-150 | closed |
| T-19.4.1-07 | Information disclosure | secrets in repositories | high | mitigate | env examples empty; secret-pattern scan clean in both repos; values generated on rome, 0640 root:summercms-demo, admin password root 0600 (DEPLOY.md). Repo privacy is host-only | closed |
| T-19.4.1-08 | Tampering | one-way plugin tags | medium | mitigate | all 11 phase tags resolve to the commits recorded in the SUMMARYs; scripts/check-pins.sh; `TestGoModRequiresTags` | closed |
| T-19.4.1-09 | Spoofing | Stripe webhook | high | mitigate | signing secret required, `pgstripe.VerifySignature` on raw body (PLG/pgstripe/subscriptions/webhook.go:72-78); empty example. Value on rome is host-only | closed |
| T-19.4.1-10 | Information disclosure | debug pages and permissive CORS | medium | mitigate | production `debug: false`; CORS only `https://demo.summercms.io`; supervisor `SUMMER_ENV="production"`; check-deploy.sh:183 | closed |
| T-19.4.1-11 | Elevation of privilege | unintended Go routes | medium | mitigate | nginx allow-list, `/` GET/HEAD only; check-deploy.sh:268-279 refuses extra `/oauth`, `/.well-known`, `/api` and subscribe locations; PAT APIs `/api/v1/*` unrouted. Narrowed by Phase 19.7 (MCP paths) | closed |
| T-19.4.1-12 | Denial of service | Nuxt site-cache purge | medium | mitigate | purge on 127.0.0.1:3098, no POST reaches Nuxt, route 404s proxied/401s bad token; payload token leak fixed in DEMO 866e58c (`NUXT_CACHE_PURGE_TOKEN`, check-deploy.sh:300-318, smoke checks) | closed |
| T-19.4.1-13 | Information disclosure | credentials on public pages | high | mitigate | empty login fields cleared after submit; FAQ answer "credentials on request"; smoke.sh:256-268 fails on admin links or visitor credentials on 12 SSR pages; check-live `no_admin_link` | closed |
| T-19.4.1-14 | Spoofing | TLS | medium | mitigate | 301 to HTTPS, certbot lineage, HSTS (nginx:50-67); check-live HSTS check. Certificate is host-only | closed |
| T-19.4.1-15 | Denial of service | port collisions | low | mitigate | check-deploy.sh:179-243,364-371 port consistency; port probe on rome is host-only | closed |
| T-19.4.1-16 | Spoofing | bot sign-ups and credential stuffing | high | mitigate | `turnstile: closed`; guard on `/login`, `/register` inside `throttle:user-api`; attempt_limit 5; production without secret → 503 (FW v0.6.1); check-live token-less register → 422; `TestDemoTurnstile` | closed |
| T-19.4.1-17 | Tampering | shared sm-user-plugin behaviour | high | mitigate | default `turnstile: ""` adds nothing, invalid values fail boot (PLG/user/routes.go:63-79); `TestTurnstileRoutes`; no other app sets the key | closed |
| T-19.4.1-18 | Denial of service | contact form spam | medium | mitigate | 10/min per client IP; field limits (message ≤5000); Origin allow-list; no mailer in feedback/demo code | closed |
| T-19.4.1-19 | Information disclosure | Turnstile secret, G15Office token | high | mitigate | empty examples; build-machine secrets file mode 600; values piped over SSH stdin and checked by count only (DEPLOY.md:114-238). File modes host-only | closed |
| T-19.4.1-20 | Tampering | demo:reset deleting kept data | high | mitigate | no-op without `--force`; explicit table list keeps admins, plans, settings, locales, sitemap, OAuth clients; one transaction, blobs after commit; schedule off by default; `TestResetKeepsPlans`; check-local.sh:262-281 | closed |
| T-19.4.1-21 | Tampering | stored XSS via seed or author description | low | mitigate | AuthorCard text interpolation; PostBody `sanitizeJournalHtml` before v-html; curated seed | closed |
| T-19.4.1-22 | Information disclosure | author payload | low | accept | AR-19.4.1-02 (rationale corrected) | closed |
| T-19.4.1-23 | Tampering | locale cookie | low | accept | AR-19.4.1-03 | closed |
| T-19.4.1-24 | Information disclosure | feedback widget key in the page | low | accept | AR-19.4.1-04 | closed |
| T-19.4.1-25 | Tampering | stored XSS and phishing through user decks | high | mitigate | wrapper restricts themes, clears CSS/context, forces unpublished, resets id/hash, 403 on published decks (DEMO/plugins/demo/decks_api.go:38-120); bluemonday sanitize without `<a>` (PLG/presentations/classes/sanitize.go:21-51); reset wipes user decks; `TestDemoDeckWrites`. See residual 3 | closed |
| T-19.4.1-26 | Elevation of privilege | cross-app realtime token reuse | high | mitigate | own Centrifugo with own HMAC secret; HMAC-tagged channels with per-subscribe DB ownership check (PLG/presentations/realtime.go:74-90); guarded token route. Owner-only access clause superseded by Phase 20 (chat community members) | closed |
| T-19.4.1-27 | Spoofing | subscribe proxy | high | mitigate | no nginx location (enforced by check-deploy.sh:273-276, check-live); constant-time `X-Centrifugo-Secret` compare, empty refused (FW lighthouse/centrifugo/handlers.go:112-113); `TestRealtimeRoutes` | closed |
| T-19.4.1-28 | Information disclosure | Centrifugo keys and remote key | high | mitigate | `CHANGE_ME_*` placeholders enforced by check-deploy.sh:213-219; empty env examples; generated on rome 0640; health prints only `api_key_set` | closed |
| T-19.4.1-29 | Spoofing | WebSocket from other sites | medium | mitigate | `allowed_origins: ["https://demo.summercms.io"]`, no insecure/anonymous flags; check-live refuses a foreign Origin | closed |
| T-19.4.1-30 | Denial of service | deck writes and token route | medium | mitigate | 256 KiB body, ≤60 slides, `throttle:20,1`; realtime bucket 120/min per user | closed |
| T-19.4.1-31 | Information disclosure | quiz answers to anonymous visitors | medium | mitigate | public list returns slug, title, description, count only; quiz API behind `jwt.auth`; `TestDemoQuizList` | closed |
| T-19.4.1-32 | Elevation of privilege | another user's decks | medium | mitigate | owner from JWT (`mineCaller`); no owner field, `DisallowUnknownFields`; queries scoped by owner_user_id | closed |
| T-19.4.1-33 | Information disclosure | test fixtures and logs | medium | mitigate | test-only secrets; fake siteverify; smoke uses random tokens and passwords, never echoed | closed |
| T-19.4.1-34 | Tampering | regression of mitigations from plans 01-04 | high | mitigate | pinning test per threat (T-01, 02, 05, 16, 17, 20, 25, 26, 27, 31; smoke/check-live for T-06, T-13); scripts/check-all.sh | closed |
| T-19.4.1-35 | Denial of service | check-live against production | low | accept | AR-19.4.1-05 | closed |
| T-19.4.1-SC | Tampering | npm/pip/cargo installs (plans 01-05) | high | mitigate | only `centrifuge 5.7.3` and `vitest 3.2.7` added, exact pins; `protobufjs: false`; `--frozen-lockfile`, `GOWORK=off`, `GOPRIVATE`; go.sum committed; no replace (go_mod_test.go:31) | 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-19.4.1-01 | T-19.4.1-04 | Seeded content is curated in the repository and every field passes `classes.Clean` at render; text values are HTML-escaped by the builder (`para()` uses `esc()`). | 19.4.1-01 PLAN | 2026-11-05 |
| AR-19.4.1-02 | T-19.4.1-22 | Plan rationale: "only subtitle, description and avatar are added; email and login data stay out of authorColumns." **Corrected:** `login` is selected (PLG/journal/controllers/api/authors.go:27-28) and published as `slug` when the author slug is empty. Holds in practice: the demo's only author is `summercms-team` (no superuser, no role, unusable password). A post by a real admin without an author slug would publish that admin's login name while `/smbackend` is public. | 19.4.1-03 PLAN (corrected by audit) | 2026-11-05 |
| AR-19.4.1-03 | T-19.4.1-23 | Only en and pl are accepted by the i18n module; any other value falls back to the default. | 19.4.1-03 PLAN | 2026-11-05 |
| AR-19.4.1-04 | T-19.4.1-24 | Public by design; the Origin allow-list and throttles gate it. | 19.4.1-03 PLAN | 2026-11-05 |
| AR-19.4.1-05 | T-19.4.1-35 | A handful of GET requests and one refused POST per run, inside the existing throttles. | 19.4.1-05 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Residual Risk and Notes (non-blocking)
1. **T-06 register row is stale in 19.4.1-02-PLAN.md** ("admin only through an SSH tunnel"). The verified mitigation is the login-required public `/smbackend` described above (user decision 2026-10-20; 02-SUMMARY flag `auth-surface`, 03-SUMMARY "Threat model update").
2. **Later phases changed mitigations:** T-11 (Phase 19.7 routes MCP and OAuth metadata paths; check-deploy enforces the narrower list) and T-26 (Phase 20 gives realtime tokens to chat community members; presentations channels still HMAC-tagged and ownership-checked). Belongs in those phases' audits.
3. **The deck wrapper (T-25) is the only public deck write path while** nginx does not route `/api/v1/presentations` **and** demo users lack `golem15.presentations.access_presentations` (needed by the Phase 19.7 presentations MCP tools, incl. `presentations_publish` and deck CSS; no demo group grants it today). If either changes, users get a write path that skips the wrapper's CSS, publish and theme rules.
4. **AR-19.4.1-02 wording** corrected above (author `login` published as slug).
5. **Local-only admin password literal:** DEMO/scripts/dev.sh:48 `ADMIN_PASSWORD=AdminDemo123!` for the local dev admin, reused as a fallback in smoke-seo.sh:74. Not a production credential, but a committed literal in a private repo.
6. **UAT:** 19.4.1-UAT.md is `testing` with 3 pending items. Item 1 (purge token in the payload) is fixed in 866e58c; DEPLOY.md:1046-1055 records the rotation on rome on 2026-10-20 (host-only). Items 2-3 are UAT, not threats.
### Host-only checks (not verifiable from the repos)
Repository privacy on git.golem15.com; rome file modes (`.env`, `site.env` 0640 root:summercms-demo, admin file 0600, centrifugo.json 0640); Stripe and Turnstile secrets present on rome; reset schedule off on rome; certbot certificate and Cloudflare real-IP snippet; port probe; purge-token rotation; build machine's 0600 secrets file.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 36 | 36 | 0 | msd-security-auditor (ASVS L1, block_on high; static, repo contents only) |
---
## 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
- [ ] Host-only checks confirmed on rome / git.golem15.com
**Approval:** verified 2026-11-05 (ASVS L1, repository evidence)

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

@@ -45,7 +45,7 @@ A third fix found by the tests is functional, not a security gap: c29d228 `fix(p
| T-19.7-07 | `payload` is accepted only for an action with a Prompt, only as a JSON string, trimmed, non-empty and capped in runes; every other record action still refuses a payload; a plain action runs with an empty Value. | F `cabana/actions.go:217`, `:256` | `TestRecordActionPromptSmoke`, `TestRecordActionPrompt`, `TestRecordActionPromptRun` (F) | verified |
| T-19.7-08 | Contributed labels, statuses, messages and URLs render as text and attributes (no `v-html`); a URL opens with `noopener` and the visible link carries `rel="noopener noreferrer"`. | F `admin/src/components/account/AccountContributions.vue` | `admin/tests/account/AccountContributions.test.ts` ("Text, never HTML", URL attributes) (F) | corrected: fixed in 44835e4, 88559d5 (F); was verified (see Corrections) |
| T-19.7-09 | wristband's authorize allow-list is exactly `Options.ScopesSupported`; the default server refuses `backend`; `openid` and unlisted scopes stay refused on the MCP flavour. | F `wristband/authorize.go:31`, `:231` | `TestMCPScopesSmoke`, `TestMCPScopes` (F) | verified |
| T-19.7-10 | Every request re-verifies its bearer (`auth.RequireBearerToken` with pier's resolver); a session never replaces it; a failure is 401 with the RFC 9728 challenge and `scope="read"`. | F `pier/pier.go:235-260`; F `pier/identity.go:144` | `TestPierTracerSmoke` (bad bearer), `TestEndpointGuards` (stream ends after revocation), `TestMCPEndToEnd` "revoked token" (F, D) | verified |
| T-19.7-10 | Every request re-verifies its bearer (`auth.RequireBearerToken` with pier's resolver); a session never replaces it; a failure is 401 with the RFC 9728 challenge naming only `resource_metadata` (no scope parameter since quick 261106-kt1, so clients request the PRM `scopes_supported`). | F `pier/pier.go:256-274`; F `pier/identity.go:144` | `TestPierTracerSmoke` (bad bearer, missing bearer), `TestEndpointGuards` (stream ends after revocation), `TestMCPEndToEnd` "revoked token" (F, D) | verified |
| T-19.7-11 | The session record stores only the binding (`oauth:<client>:<user>` or the personal token id), compared on every POST, GET and DELETE (403 `session_binding`); ids from `rand.Text()`; sliding idle TTL; per-binding cap. | F `pier/session.go:143`, `:211`, `:242`, `:139` | `TestPierSessionsSmoke`, `TestSessions` (Redis and memory) (F) | verified |
| T-19.7-12 | The inner request is built fresh (`httptest.NewRequestWithContext`) with no Authorization or Cookie; identity travels only in the marker; client address, X-Forwarded-For, X-Real-IP and Accept-Language are copied. | F `pier/dispatch.go:159-180` | `TestPierTracerSmoke` (echo route `auth: false`), `TestDispatch` (outer Cookie and Authorization absent) (F) | verified |
| T-19.7-13 | A backend subject exists only with `backend` in the grant AND a link the resolver confirms per request (5 s cache), or an admin `g15_` token behind `mcp.allow_personal_tokens`; the read/write ceiling applies on top; list and call use the same `allowed`. | F `pier/identity.go:152`, `:187-198`; F `pier/filter.go:16`, `:37` | `TestToolsList` (no link, no backend tool; union with a link), `TestPierTracerSmoke` "personal tokens behind the flag", `TestMCPEndToEnd` (no backend tool without the scope) (F, D) | verified |

View File

@@ -29,19 +29,19 @@ findings:
title: "Process's plain path prints item names without Sanitize"
- id: IN-10
severity: info
disposition: deferred
disposition: fixed
title: "console.Error during a live view writes straight to the terminal and tears the view"
- id: IN-11
severity: info
disposition: deferred
disposition: fixed
title: "A blank line printed during a live view is dropped"
- id: IN-12
severity: info
disposition: deferred
disposition: fixed
title: "Signal relay edge cases: a signal during view start-up, and Windows"
- id: IN-13
severity: info
disposition: deferred
disposition: fixed
title: "A delegate's stderr always goes to os.Stderr, bypassing the root's error writer"
- id: IN-14
severity: info
@@ -97,39 +97,39 @@ findings:
title: "The wrong-directory hint repeats every argument, including a --password value, on stderr"
- id: IN-01
severity: info
disposition: deferred
disposition: fixed
title: "A forced-color console drops HideOnTTY records even when stderr is not a terminal"
- id: IN-02
severity: info
disposition: deferred
disposition: fixed
title: "PeekGlobals and cobra disagree on some argument forms"
- id: IN-03
severity: info
disposition: deferred
disposition: fixed
title: "isUsage recognises cobra's unknown-command error by its text"
- id: IN-04
severity: info
disposition: deferred
disposition: fixed
title: "The --json flag is read two different ways"
- id: IN-05
severity: info
disposition: deferred
disposition: fixed
title: "summer's scaffolding result lines are now hidden by -q"
- id: IN-06
severity: info
disposition: deferred
disposition: fixed
title: "fang replaces the root help function that NewRootIO installs"
- id: IN-07
severity: info
disposition: deferred
disposition: fixed
title: "diary pulls the whole TUI stack into security modules"
- id: IN-08
severity: info
disposition: deferred
disposition: fixed
title: "The console sanitizer passes raw C1 bytes through on its fast path"
- id: IN-09
severity: info
disposition: deferred
disposition: fixed
title: "The phase gate names consuming applications"
open: 0
total: 32
@@ -141,15 +141,15 @@ recorded: 2026-10-22T01:19:33.337Z
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| WR-09 | warning | fixed | fix fce232d (quick 261022-4z8) |
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); the exit-130 suggestion is deferred to todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
| WR-10 | warning | fixed | fix 17c722d (quick 261022-4z8); follow-up bb7e14b (quick 261028-rqd): a delegate killed by a signal exits 128+n, silently for SIGINT and SIGTERM |
| WR-11 | warning | fixed | fix 707372b (quick 261022-4z8) |
| WR-12 | warning | fixed | fix 57a849d, reworked in 139f5e3 so that lagoon.Status stays plugin-only and migrate:status adds the framework sets itself (quick 261022-4z8) |
| WR-13 | warning | fixed | fix 0691d26 (quick 261022-4z8) |
| WR-14 | warning | fixed | fix 48e13f9 (quick 261022-4z8) |
| IN-10 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
| IN-11 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
| IN-12 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
| IN-13 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md |
| IN-10 | info | fixed | fix 6efb9a4 (quick 261028-rqd) |
| IN-11 | info | fixed | fix 4d75695 (quick 261028-rqd) |
| IN-12 | info | fixed | fix f91d70d (quick 261028-rqd); part 2 (Windows) not changed, documented in raise's doc comment: Go cannot report another handler, and returning without exiting would swallow Ctrl+C |
| IN-13 | info | fixed | fix 4914cad, bonfire.ErrWriter (quick 261028-rqd) |
| IN-14 | info | fixed | docs 7c15095 (quick 261022-4z8) |
| IN-15 | info | fixed | fix 1ac75ad (quick 261022-4z8) |
| CR-01 | critical | fixed | fix b077dc3 (19.8-05) (not in the current review) |
@@ -163,15 +163,15 @@ recorded: 2026-10-22T01:19:33.337Z
| WR-06 | warning | fixed | fix 39677f1 (19.8-05) (not in the current review) |
| WR-07 | warning | fixed | fix 020fe83 (19.8-05) (not in the current review); remainder WR-12 (fix 57a849d) |
| WR-08 | warning | fixed | fix 9b62285 (19.8-05) (not in the current review); remainder WR-11 (fix 707372b) |
| IN-01 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-02 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-03 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-04 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-05 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-06 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-07 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-08 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-09 | info | deferred | todo .planning/todos/pending/2026-10-22-console-review-info-findings.md (not in the current review) |
| IN-01 | info | fixed | fix 3520d5d (quick 261028-rqd) (not in the current review) |
| IN-02 | info | fixed | fix e34d113 (quick 261028-rqd) (not in the current review) |
| IN-03 | info | fixed | fix 39c59b8 (quick 261028-rqd) (not in the current review) |
| IN-04 | info | fixed | feat 795c4f6, bonfire.FlagBool (quick 261028-rqd); summer's flagTrue/flagBool and flare's flagSet keep their wider value sets for compatibility (not in the current review) |
| IN-05 | info | fixed | docs 2da42e9 (quick 261028-rqd): documentation fix, the code is unchanged (0.4 shipped the lines; -q hiding them is now documented) (not in the current review) |
| IN-06 | info | fixed | refactor 53c0a65, fang removed; planning docs 6294ac4 (quick 261028-rqd) (not in the current review) |
| IN-07 | info | fixed | refactor f3f87f3, internal/consolekit (quick 261028-rqd) (not in the current review) |
| IN-08 | info | fixed | fix 8476a65 (quick 261028-rqd) (not in the current review) |
| IN-09 | info | fixed | fix 10fec47 (quick 261028-rqd) (not in the current review) |
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.

View File

@@ -0,0 +1,421 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-chat-plugin/events/events.go
- ../sm-chat-plugin/contracts/rejection.go
- ../sm-chat-plugin/classes/service.go
- ../sm-chat-plugin/classes/writes.go
- ../sm-chat-plugin/api/errors.go
- ../sm-chat-plugin/saving_smoke_test.go
- ../sm-chat-plugin/README.md
- ../sm-forum-plugin/events/events.go
- ../sm-forum-plugin/contracts/rejection.go
- ../sm-forum-plugin/classes/forum_service.go
- ../sm-forum-plugin/classes/lifecycle.go
- ../sm-forum-plugin/api/errors.go
- ../sm-forum-plugin/saving_smoke_test.go
- ../sm-forum-plugin/README.md
- ../sm-chatvideo-plugin/go.mod
- ../sm-chatvideo-plugin/go.sum
- ../sm-chatvideo-plugin/README.md
- ../sm-herald-plugin/go.mod
- ../sm-herald-plugin/go.sum
- ../sm-herald-plugin/README.md
- ../sm-userfriends-plugin/go.mod
- ../sm-userfriends-plugin/go.sum
- ../sm-userfriends-plugin/README.md
- ../sm-moderation-plugin/.gitignore
- ../sm-moderation-plugin/go.mod
- ../sm-moderation-plugin/go.sum
- ../sm-moderation-plugin/go.work
- ../sm-moderation-plugin/plugin.go
- ../sm-moderation-plugin/admin.go
- ../sm-moderation-plugin/admin_permissions.go
- ../sm-moderation-plugin/logger.go
- ../sm-moderation-plugin/classes/filter.go
- ../sm-moderation-plugin/classes/filter_smoke_test.go
- ../sm-moderation-plugin/models/settings.go
- ../sm-moderation-plugin/models/registry.go
- ../sm-moderation-plugin/models/settings/fields.yaml
- ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go
- ../sm-moderation-plugin/updates/registry.go
- ../sm-moderation-plugin/lang/en/lang.yaml
- ../sm-moderation-plugin/lang/pl/lang.yaml
- ../sm-moderation-plugin/config/config.yaml
- ../sm-moderation-plugin/README.md
autonomous: false
requirements: [SC-1, D-17, D-18, D-19, D-20]
estimate:
tokens: 220000
raw_tokens: 220000
tasks: 6
confidence: low
must_haves:
truths:
- "D-18 (chat): with a festival listener on events.MessageSaving that returns contracts.Rejection{Field: \"content\", Message: m}, POST /_chat/api/v1/conversations/{id}/messages, POST /_chat/api/v1/messages/{id}/replies and PUT /_chat/api/v1/messages/{id} answer 422 {\"error\":\"Validation failed\",\"errors\":{\"content\":[m]}}; no golem15_chat_messages row is inserted or changed, MessageCreated and MessageUpdated do not fire and nothing is published."
- "D-18 (forum): ForumService.CreateThread, Reply and EditPost fire events.PostSaving before breakwater.Render; a contracts.Rejection from a listener maps to the existing 422 envelope under errors.title or errors.body and nothing is persisted."
- "D-18 (parity): with no MessageSaving or PostSaving listener every existing route, payload, status, table, migration ID and config key of sm-chat-plugin and sm-forum-plugin is unchanged; their php_method_map_test.go and golden_test.go pass without edits; the seams name no application and ship as patch-level additions."
- "D-17: the new shared plugin golem15.moderation (module git.golem15.com/golem15/sm-moderation-plugin) publishes *classes.Filter at boot; Filter.Check returns a Violation for a banned word or phrase (case-insensitive, Unicode letter boundaries, Polish letters included) or a banned link (a listed domain, its subdomains, with or without scheme or www, and URL-prefix entries), and nil for clean text; the lists are the banned_words and banned_links textareas of its admin settings screen (singleton golem15_moderation_settings) behind the permission golem15.moderation.manage_settings; the plugin imports neither chat nor forum."
- "SC-1: sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin go.mod files keep only the framework replace and require sm-chat-plugin v0.1.3, sm-user-plugin v0.1.13 and summercms v0.5.1 where they depend on them; every one of the eight repos passes go vet and go test -short, and the five chat dependants build with GOWORK=off."
- "SC-1 (release, after the Task 5 go-ahead): origin of each repo carries its new tag on the pushed HEAD that passed the checks: sm-chat-plugin v0.1.4 (patch, the seam), sm-chatchannels-plugin, sm-chatbots-plugin, sm-chatvideo-plugin, sm-forum-plugin, sm-herald-plugin, sm-userfriends-plugin and sm-moderation-plugin v0.1.0 (or the next free number read from the remote); a scratch module requiring all eight tags and summercms v0.5.1 builds with GOWORK=off."
- "D-20 (D-19 superseded): this is plan 01 of the six plans confirmed at plan time; the chat family release precedes the demo backend (plan 02)."
artifacts:
- path: "../sm-chat-plugin/events/events.go"
provides: "MessageSaving pre-save event and MessageSavingEventName"
contains: "MessageSaving"
- path: "../sm-chat-plugin/contracts/rejection.go"
provides: "Rejection{Field, Message} error a pre-save listener returns"
contains: "type Rejection struct"
- path: "../sm-forum-plugin/events/events.go"
provides: "PostSaving pre-save event and PostSavingEventName"
contains: "PostSaving"
- path: "../sm-forum-plugin/contracts/rejection.go"
provides: "Rejection{Field, Message} for forum listeners"
contains: "type Rejection struct"
- path: "../sm-moderation-plugin/classes/filter.go"
provides: "Filter, NewFilter, Check, Match, ParseList, Violation"
contains: "func (f *Filter) Check"
- path: "../sm-moderation-plugin/admin.go"
provides: "moderation_settings settings item (HasSettings, HasLang)"
contains: "moderation_settings"
key_links:
- from: "../sm-chat-plugin/classes/service.go"
to: "../sm-chat-plugin/events/events.go"
via: "SendMessageWith fires MessageSaving on s.bus before lagoon.Transaction"
pattern: "MessageSaving\\{"
- from: "../sm-chat-plugin/api/errors.go"
to: "../sm-chat-plugin/contracts/rejection.go"
via: "writeChatError maps errors.As(err, &contracts.Rejection) to writeValidation"
pattern: "errors\\.As\\(err, &rej\\)"
- from: "../sm-forum-plugin/classes/forum_service.go"
to: "../sm-forum-plugin/events/events.go"
via: "CreateThread and Reply fire PostSaving on s.bus before breakwater.Render"
pattern: "PostSaving\\{"
- from: "../sm-moderation-plugin/plugin.go"
to: "../sm-moderation-plugin/classes/filter.go"
via: "Boot publishes classes.NewFilter(db) through app.Publish inside lagoon.OnDatabase"
pattern: "app\\.Publish"
prohibitions:
- statement: "No existing route, request or response field, status code, table, migration ID or config key of sm-chat-plugin or sm-forum-plugin changes; the seams are additive (WavePath parity, Phase 31)."
status: unresolved
verification: automated
- statement: "sm-moderation-plugin depends on neither sm-chat-plugin nor sm-forum-plugin."
status: unresolved
verification: automated
- statement: "No README, comment or identifier in the eight repos names a consuming application."
status: unresolved
verification: judgment
- statement: "Nothing is pushed, no remote repository is created and no tag is set before the Task 5 go-ahead."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here, see the planning return): `sm-demo-app` runs every shared Golem15 plugin collected so far in one binary, and its public Nuxt site shows them working: chat and channels with realtime, video calls, presentations, quizzes, journal, FAQ, subscriptions and the rest. Phase 19.4.1 deployed the demo early; this phase adds the chat family to that live instance.
This plan's slice (plan 1 of 6, wave 1, per D-20): the shared plugin release the demo needs before it can require the chat family by tag. Fast-forward the stale sibling checkouts, add the generic pre-save seams to chat and forum (D-18), create `sm-moderation-plugin` (D-17), fix the go.mod files that break module mode, and, after the user's go-ahead, push and tag all eight repositories.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Seam shape (D-18 discretion):** a festival "-ing" event fired before the write transaction, mirroring `user.Deleting` / `user.Deactivating` in sm-user-plugin. chat gets `events.MessageSaving`, forum a new `events` package with `events.PostSaving`. A listener refuses by returning the plugin's own `contracts.Rejection{Field, Message}`. Each plugin declares its own type, so forum does not import chat and neither imports moderation. The API maps `errors.As(err, &rej)` to the existing 422 envelope. Rationale: `festival.Bus.Fire` already returns the joined listener errors, chat already holds `s.bus`, forum holds an unused `Bus` field, and no service gains a new registration method. RESEARCH Pattern 2.
- **Event names:** `MessageSavingEventName = "golem15.chat.message.saving"` and `PostSavingEventName = "golem15.forum.post.saving"`. They are Go-only seams with no PHP counterpart; the doc comments say so.
- **Op values:** chat `OpCreate = "create"` and `OpUpdate = "update"`; forum `OpThread = "thread"`, `OpReply = "reply"` and `OpUpdate = "update"`.
- **Moderation lists:** two textareas, one entry per line; an `enabled` switch defaults to true; at most 500 entries of at most 200 characters per list (validated in `BeforeValidate`). The filter reads the singleton row on every `Check` and recompiles its matchers only when the stored text changed. RE2's `\b` is ASCII-only, so word boundaries are built from `[^\p{L}\p{N}_]` to cover Polish letters.
- **Violation message:** `Violation{Kind, Match, Message}`. Message comes from the plugin's lang files (`golem15.moderation::lang.violation.word` and `.link`, EN and PL) in the locale `towel.Locale(ctx)` carries, resolved through the framework's phrasebook. Without a locale it is English. The chat-family APIs answer in English today, so English is an acceptable fallback.
- **Dependants require existing tags:** chatvideo, herald and userfriends require `sm-chat-plugin v0.1.3` (the newest tag before this plan), `sm-user-plugin v0.1.13` and `summercms v0.5.1`, the set RESEARCH compiled. That keeps their module-mode check independent of the new chat tag; minimal version selection gives the demo chat v0.1.4.
- **Repository creation:** with the go-ahead, Claude creates `golem15/sm-moderation-plugin` on git.golem15.com with `tea` when `tea login list` shows a git.golem15.com login, using the same visibility as `sm-sitemap-plugin`. Otherwise the user creates it.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- sm-chat-plugin (v0.1.4): `events.MessageSaving`, `events.MessageSavingEventName`, `events.OpCreate`, `events.OpUpdate`, `contracts.Rejection` (`Error()`), the 422 mapping in `writeChatError`, smoke test `TestMessageSavingTracer` (`saving_smoke_test.go`), a README changelog entry v0.1.4.
- sm-forum-plugin (v0.1.0): package `events` (`PostSaving`, `PostSavingEventName`, `OpThread`, `OpReply`, `OpUpdate`), `contracts.Rejection`, the 422 mapping in `writeForumError`, smoke test `TestPostSavingTracer`.
- sm-moderation-plugin (new repo, v0.1.0): plugin `golem15.moderation`, package `moderation`, `classes.Filter`, `classes.NewFilter(db *gorm.DB) *Filter`, `(*Filter).Check(ctx context.Context, text string) (*Violation, error)`, `classes.Match(words, links []string, text string) *Violation`, `classes.ParseList(text string) []string`, `classes.Violation{Kind, Match, Message}`, `classes.KindWord`, `classes.KindLink`, table `golem15_moderation_settings` (migration `202610280001_create_golem15_moderation_settings`), settings item `moderation_settings`, permission `golem15.moderation.manage_settings`, config keys `golem15.moderation.max_entries` and `golem15.moderation.max_entry_length`, smoke test `TestFilterSmoke`.
- go.mod fixes in sm-chatvideo-plugin, sm-herald-plugin and sm-userfriends-plugin.
- Tags: chat v0.1.4; chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation v0.1.0 (numbers read from the remote at release time).
- Full unit tests come in plan 06 (project rule).
<objective>
Release the chat family so the demo can require it by tag: the D-18 pre-save seams in chat and forum, the D-17 moderation plugin, go.mod hygiene, and eight tags after the user's go-ahead.
Purpose: SC-1 needs `sm-demo-app` to build from tagged modules (19.4.1 D-03, `TestGoModRequiresTags`, `scripts/check-pins.sh`). D-17 and D-18 need the seams and the filter before plan 02 wires them. D-20 makes this plan 01 of six and supersedes D-19's earlier split.
Output: commits and, after the go-ahead, pushes and tags in eight plugin repositories. No change in sm-demo-app, vue-demo-app or summercms.go code.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
@.planning/notes/core-plugins-own-repos.md
Repos (siblings of summercms.go): ../sm-chat-plugin, ../sm-chatchannels-plugin, ../sm-chatbots-plugin (all three behind origin: fast-forward first), ../sm-chatvideo-plugin, ../sm-forum-plugin, ../sm-herald-plugin, ../sm-userfriends-plugin, and the new ../sm-moderation-plugin. Read-only references: ../sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots} (the origin-main trees, for reading only), ../sm-sitemap-plugin (plugin and settings analog), ../sm-demo-app/plugins/golem15/user/classes/events.go (the "-ing" event analog).
Rules: no co-author tags in commits (user instruction overrides any attribution default); stage files by explicit path, never `git add -A`; one logical change per commit; nothing pushed, created or tagged before Task 5. Verify commands run from the framework root and address siblings with `go -C ../<repo>`. Plugin repos with a tracked go.work resolve their siblings through it (the seam-carrying ../sm-chat-plugin included). If the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md).
<interfaces>
chat (origin main 51c9e17 = v0.1.3, same tree as ../sm-bm-app/plugins/golem15/chat):
- `classes.Service` fields `bus *festival.Bus` (from `Options.Bus`); `SendMessageWith(ctx, in SendInput) (*models.Message, error)` validates metadata, loads the conversation, checks `canSend`, trims content, returns `ErrEmptyMessage` when content and attachments are empty, then opens `lagoon.Transaction` and fires `events.MessageCreated` inside it (classes/service.go:287-345). `SendMessage` and `ReplyTo` (classes/attachments.go:218) funnel into it.
- `EditMessage(ctx, actorID, messageID uint, content string) (*models.Message, error)` in classes/writes.go:248, after `CanEditMessage` (writes.go:202).
- events/events.go: `MessageEvent{MessageID, ConversationID, ActorID uint; RootID *uint; Tx *gorm.DB}`, `MessageCreated`, `MessageUpdated`, `MessageDeleted`, consts `MessageCreatedEventName` etc.; the package doc says every event fires inside the transaction.
- api/errors.go: `writeValidation(w, map[string][]string)`, `writeChatError(w, what, err)` (switch over errors.Is, default 500).
- Test helpers: harness_test.go `bootChat`, `(*chatEnv).user`, `.count`, `.memory`, `.conversation(t, createdBy, typ, alias, contextID, participants...)`; http_tracer_test.go `bootWeb`, `(*webEnv).frontendToken`, `.call(t, method, path, token, body)`, `decodeObject`.
forum (../sm-forum-plugin, origin main 2779341):
- `classes.ForumService{bus *festival.Bus}` built in boot.go `bootService` with `Bus: app.Events`; `CreateThread(ctx, actorID, contextType, contextID, categoryID, title, body)` renders at forum_service.go:281, `Reply(...)` at :330, `EditPost(ctx, actorID, contextType, contextID, postID, body)` in classes/lifecycle.go:217. No `Fire(` call exists in the plugin today.
- api/errors.go: `writeValidation`, `writeForumError` (ErrForbidden/ErrNotFound 404, ErrTitleRequired/ErrBodyRequired 422).
- Test helpers: harness_test.go `bootForum`, `.user`, `.count`; http_tracer_test.go `bootWeb`, `.frontendToken`, `.call`.
festival (framework): `(*Bus).Listen[T any](owner string, fn func(context.Context, T) error)`, `(*Bus).Fire[T any](ctx, event T) error` returns errors.Join of listener errors (callers use errors.As).
sitemap analog: plugin.go (embed config, `pact.HasConfig/HasMigrations/HasModels`), admin.go (`Settings() []pact.SettingsItem` with Code, Label, Description, Category, Icon, Permissions, Form, NewModel; `AdminFS`, `LangFS`), admin_permissions.go (`Permissions() []pact.Permission`), models/settings.go (singleton row id 1, `*string` columns, `BeforeValidate` with a lang-key error), models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go (CREATE TABLE plus INSERT id 1), go.mod replace `git.golem15.com/golem15/summercms => ../summercms.go`, tracked go.work.
go.mod today: chatvideo requires chat v0.0.0 and replaces chat and user with sibling paths; herald replaces user with ../fonoteka.go/plugins/golem15/user; userfriends requires chat pseudo-version v0.0.0-20261017200243-4d0e764ca443.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a rejecting MessageSaving listener turns a chat send into the existing 422 envelope and nothing is stored</name>
<reversibility rating="costly">D-18: the seam becomes a public plugin contract other consumers can start using; changing its shape later touches every listener.</reversibility>
<precondition>`git -C ../sm-<name>-plugin status --porcelain` prints nothing for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends; `docker info` exits 0.</precondition>
<files>../sm-chat-plugin/events/events.go, ../sm-chat-plugin/contracts/rejection.go, ../sm-chat-plugin/classes/service.go, ../sm-chat-plugin/classes/writes.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/saving_smoke_test.go, ../sm-chat-plugin/README.md</files>
<read_first>../sm-chat-plugin/classes/service.go (SendMessageWith, Options.Bus), ../sm-chat-plugin/classes/writes.go (EditMessage, CanEditMessage), ../sm-chat-plugin/classes/attachments.go (ReplyTo), ../sm-chat-plugin/events/events.go, ../sm-chat-plugin/api/errors.go, ../sm-chat-plugin/api/messages.go and api/replies.go, ../sm-chat-plugin/http_tracer_test.go and harness_test.go, ../sm-chat-plugin/php_method_map_test.go, ../sm-chat-plugin/README.md, ../sm-demo-app/plugins/golem15/user/classes/events.go (DeactivatingEvent doc style), modules/festival/bus.go, 20-RESEARCH.md (Pattern 2, Pitfall 1), 20-PATTERNS.md (Chat / forum pre-save seam)</read_first>
<action>Per D-18 and D-20; wire one path end to end: listener, service, API, HTTP response, database unchanged.
1. Fast-forward every sibling first (RESEARCH Pitfall 1): for chat, chatchannels, chatbots, chatvideo, forum, herald and userfriends run `git -C ../<repo> pull --ff-only` and confirm `git -C ../<repo> rev-parse HEAD` equals the hash `git -C ../<repo> ls-remote origin refs/heads/main` prints (use the default branch each repo tracks). Record both hashes per repo for the SUMMARY. A non-fast-forward stops the task.
2. events/events.go: keep the three existing events untouched. Add `MessageSavingEventName = "golem15.chat.message.saving"` on its own line and a `MessageSaving` struct with `ConversationID`, `ActorID` and `MessageID uint` (MessageID is 0 on create), `RootID *uint`, `Content string` and `Op string`, plus the constants `OpCreate = "create"` and `OpUpdate = "update"`. MessageSaving carries no Tx because it fires before the transaction. Rewrite the package doc: the three message events fire inside the transaction after the save; MessageSaving fires before it, has no PHP counterpart, and a listener error aborts the write; a listener refuses with contracts.Rejection.
3. contracts/rejection.go: `type Rejection struct { Field, Message string }` with a value-receiver `Error() string` returning "golem15.chat: rejected " + Field + ": " + Message. The doc comment explains that listeners return the value (not a pointer) and callers match it with errors.As, because Fire joins listener errors.
4. classes/service.go `SendMessageWith`: after the ErrEmptyMessage check and before `lagoon.Transaction`, when `s.bus != nil`, fire `&events.MessageSaving{Op: events.OpCreate, ConversationID, ActorID: senderID, RootID: rootID, Content: content}` (the trimmed content) and return `nil, err` on error. classes/writes.go `EditMessage`: after CanEditMessage allows the edit and the new content is validated, before its transaction, fire the same event with `Op: events.OpUpdate` and the MessageID. No other line of either method changes.
5. api/errors.go `writeChatError`: before the existing switch, declare `var rej contracts.Rejection` and, when `errors.As(err, &rej)`, call `writeValidation(w, map[string][]string{field: {rej.Message}})` with field = rej.Field, or "content" when Field is empty, then return. The other branches stay byte-identical.
6. saving_smoke_test.go `TestMessageSavingTracer` (Docker; skips under -short like the other HTTP tests): boot with bootWeb; register on the app's event bus a `*events.MessageSaving` listener returning `contracts.Rejection{Field: "content", Message: "not allowed"}` when Content contains the word "blocked"; create a group conversation with two participants through the harness; then
- POST `/_chat/api/v1/conversations/{id}/messages` with content "this is blocked": 422, body errors.content equals ["not allowed"], and golem15_chat_messages has 0 rows;
- POST the content "hello": 201, 1 row;
- PUT `/_chat/api/v1/messages/{id}` with "blocked edit": 422, and the stored content is still "hello";
- POST `/_chat/api/v1/messages/{id}/replies` with "blocked reply": 422, and the row count is still 1.
7. README.md: in Features and API reference, add MessageSaving (when it fires, no Tx, Op values), MessageSavingEventName, OpCreate, OpUpdate and contracts.Rejection, with a neutral example host listener (`acme`). Add a changelog entry "v0.1.4: pre-save MessageSaving event and Rejection (additive; no listener means no change)". Name no consuming application.
Commit in ../sm-chat-plugin by path (`feat: pre-save MessageSaving event and Rejection`), README in the same commit. Do not push or tag.</action>
<verify>
<automated>go -C ../sm-chat-plugin vet ./... &amp;&amp; go -C ../sm-chat-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v . 2&gt;&amp;1 | grep -q -- '^--- PASS: TestMessageSavingTracer'</automated>
<fails_when>Non-zero exit: vet or the short suite red (php_method_map or golden tests broken by the change), or no "--- PASS: TestMessageSavingTracer" line (Docker unavailable so it skipped, a 201 where 422 was expected, or a stored row after a refusal).</fails_when>
</verify>
<acceptance_criteria>
- `go -C ../sm-chat-plugin test -count=1 ./...` (with Docker) passes, including the unchanged php_method_map_test.go and golden_test.go.
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go testdata` prints nothing.
- `grep -c 'MessageSavingEventName = "golem15.chat.message.saving"' ../sm-chat-plugin/events/events.go` prints 1.
- `grep -c 'errors.As(err, &rej)' ../sm-chat-plugin/api/errors.go` prints 1.
- `grep -c 'v0.1.4' ../sm-chat-plugin/README.md` prints at least 1.
- The SUMMARY records the before and after HEAD of all seven siblings, and each after-HEAD equals origin's default branch.
</acceptance_criteria>
<done>A host can refuse a chat message before it is stored, the client gets the existing 422 envelope, and without a listener chat behaves byte-identically.</done>
</task>
<task type="auto">
<name>Task 2: The forum gets the same seam: PostSaving before render on thread, reply and edit</name>
<reversibility rating="costly">D-18: a new public event package and Rejection type in a WavePath port-target plugin; later consumers depend on the field names.</reversibility>
<files>../sm-forum-plugin/events/events.go, ../sm-forum-plugin/contracts/rejection.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/saving_smoke_test.go, ../sm-forum-plugin/README.md</files>
<read_first>../sm-forum-plugin/classes/forum_service.go (Options, New, CreateThread, Reply), ../sm-forum-plugin/classes/lifecycle.go (EditPost), ../sm-forum-plugin/boot.go (bootService passes app.Events), ../sm-forum-plugin/api/errors.go, ../sm-forum-plugin/api/threads.go and api/posts.go, ../sm-forum-plugin/http_tracer_test.go, harness_test.go and fixture_plugin_test.go (test access resolver), ../sm-forum-plugin/php_method_map_test.go, ../sm-forum-plugin/README.md, ../sm-chat-plugin/events/events.go and contracts/rejection.go (Task 1, for the shape)</read_first>
<action>Per D-18.
- New package events (events/events.go):
- `PostSavingEventName = "golem15.forum.post.saving"`;
- `PostSaving` with `ContextType string`, `ContextID`, `ActorID`, `ThreadID` and `PostID uint`, `Title`, `Body` and `Op string`;
- constants `OpThread = "thread"` (ThreadID 0, Title set), `OpReply = "reply"` and `OpUpdate = "update"`.
- The package doc says forum fires no post-save events, PostSaving is a Go-only seam fired before render and persistence, and a listener error aborts the write.
- contracts/rejection.go: the same shape as chat's, with "golem15.forum: rejected " in Error().
- ForumService, all guarded by `s.bus != nil`, firing after the access and required-field checks and before `breakwater.Render`, returning the error unchanged:
- CreateThread fires PostSaving with OpThread, the trimmed title and the body;
- Reply fires it with OpReply, the ThreadID and the body;
- EditPost (lifecycle.go) fires it with OpUpdate, the PostID, its ThreadID and the body.
- api/errors.go `writeForumError`: before the switch, `errors.As(err, &rej)` writes 422 `{rej.Field: [rej.Message]}`, with field "body" when Field is empty.
- saving_smoke_test.go `TestPostSavingTracer` (Docker): boot through bootWeb with the fixture resolver that allows posting; register a PostSaving listener rejecting Title or Body containing "blocked" (Field "title" or "body"). Then:
- POST a thread whose title holds the word: 422 errors.title, no thread and no post row;
- a clean thread: 201;
- a reply whose body holds the word: 422 errors.body;
- PUT the first post with the word: 422, and content_html is unchanged.
- README: add Features "Pre-save seam" and the API reference rows (events.PostSaving, PostSavingEventName, Op constants, contracts.Rejection), and correct the Overview so it says forum fires only this pre-save event. Add a changelog line "v0.1.0: first tag; adds the pre-save PostSaving seam". Name no application.
Commit in ../sm-forum-plugin by path (`feat: pre-save PostSaving event and Rejection`). Do not push or tag.</action>
<verify>
<automated>go -C ../sm-forum-plugin vet ./... &amp;&amp; go -C ../sm-forum-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v . 2&gt;&amp;1 | grep -q -- '^--- PASS: TestPostSavingTracer'</automated>
<fails_when>Non-zero exit: vet or the short suite red, or no "--- PASS: TestPostSavingTracer" line (skipped without Docker, a refused write that still stored a row, or the wrong 422 field).</fails_when>
</verify>
<acceptance_criteria>
- `go -C ../sm-forum-plugin test -count=1 ./...` passes with Docker, php_method_map_test.go and golden_test.go unedited (`git -C ../sm-forum-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` prints nothing).
- `grep -rn 'sm-chat-plugin' ../sm-forum-plugin/go.mod ../sm-forum-plugin/events ../sm-forum-plugin/contracts` prints nothing.
- `grep -c 'PostSaving{' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -c 'PostSaving{' ../sm-forum-plugin/classes/lifecycle.go` prints 1.
</acceptance_criteria>
<done>Forum threads, replies and edits can be refused before render and storage with the existing 422 envelope; without a listener nothing changes.</done>
</task>
<task type="auto">
<name>Task 3: New shared plugin sm-moderation-plugin: banned words and links, editable in admin settings, published as a Filter</name>
<files>../sm-moderation-plugin/.gitignore, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum, ../sm-moderation-plugin/go.work, ../sm-moderation-plugin/plugin.go, ../sm-moderation-plugin/admin.go, ../sm-moderation-plugin/admin_permissions.go, ../sm-moderation-plugin/logger.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/filter_smoke_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/registry.go, ../sm-moderation-plugin/models/settings/fields.yaml, ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go, ../sm-moderation-plugin/updates/registry.go, ../sm-moderation-plugin/lang/en/lang.yaml, ../sm-moderation-plugin/lang/pl/lang.yaml, ../sm-moderation-plugin/config/config.yaml, ../sm-moderation-plugin/README.md</files>
<read_first>../sm-sitemap-plugin/plugin.go, admin.go, admin_permissions.go, logger.go, models/settings.go, models/registry.go, models/settings/fields.yaml, updates/202610110002_create_golem15_sitemap_settings.go, updates/registry.go, lang/en/lang.yaml, config/config.yaml, go.mod, go.work, README.md (the whole plugin layout), modules/phrasebook/README.md and modules/towel/README.md (locale lookup), `summer help make:plugin` (scaffold option), .planning/notes/core-plugins-own-repos.md, 20-PATTERNS.md (sm-moderation-plugin)</read_first>
<action>Per D-17. Create the repository at /media/nvme/dev/golem15/summercms.io/summercms/sm-moderation-plugin with `git init -b main`. Add no remote yet. Start from `summer make:plugin golem15.moderation` if it emits a standalone module layout, otherwise copy the sitemap layout. Module `git.golem15.com/golem15/sm-moderation-plugin`, package `moderation`, plugin ID `golem15.moderation`, `Requires()` nil, display name Moderation / Moderacja. Standard library plus gorm, gormigrate and framework modules only.
- go.mod: go 1.27.0, require `git.golem15.com/golem15/summercms v0.5.1` plus gorm and gormigrate at the versions sitemap's go.mod uses, and `replace git.golem15.com/golem15/summercms => ../summercms.go`. Run `go mod tidy`. Track a go.work with `use .` and the same replace, as sitemap does.
- plugin.go: the interface assertions (party.Plugin, pact.HasConfig, pact.HasMigrations, pact.HasModels) and `//go:embed config`. Register keeps the app. Boot calls lagoon.OnDatabase and publishes `classes.NewFilter(db)` with `app.Publish`. The package doc says the plugin is a content filter other plugins' hosts wire into their pre-save seams, and that it imports no content plugin.
- config/config.yaml: `max_entries: 500`, `max_entry_length: 200`.
- models/settings.go: `Settings` singleton on `golem15_moderation_settings` (`ID uint`, `Enabled bool`, `BannedWords *string`, `BannedLinks *string`). `BeforeValidate` refuses:
- a list longer than max_entries (lang key `golem15.moderation::lang.errors.too_many`);
- an entry longer than max_entry_length (`errors.too_long`);
- a link entry that is neither a host name nor an http(s) URL (`errors.link`).
models/registry.go `All()`.
- models/settings/fields.yaml: `enabled` (switch), `banned_words` (textarea, comment "one word or phrase per line"), `banned_links` (textarea, comment "one domain or URL per line; subdomains match"), with the sitemap header comment block.
- updates/202610280001_create_golem15_moderation_settings.go: `CREATE TABLE IF NOT EXISTS golem15_moderation_settings (id INTEGER PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT TRUE, banned_words TEXT NULL, banned_links TEXT NULL)` and `INSERT INTO golem15_moderation_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`. Rollback drops the table. updates/registry.go `All()`.
- admin.go: `Settings()` returns item Code `moderation_settings` (lang keys for label, description and category, Icon "shield", Permissions [`golem15.moderation.manage_settings`], Form `models/settings/fields.yaml`, NewModel), plus AdminFS and LangFS, with the sitemap assertions (AdminAssets, HasSettings, HasLang). admin_permissions.go declares the permission on the role list sitemap uses.
- lang/en and lang/pl: plugin name, settings label, description and category, field labels and comments, `errors.*`, `violation.word` ("The text contains a word that is not allowed." / "Tekst zawiera niedozwolone słowo.") and `violation.link` ("The text contains a link that is not allowed." / "Tekst zawiera niedozwolony link.").
- classes/filter.go:
- `KindWord = "word"`, `KindLink = "link"`; `Violation{Kind, Match, Message string}`;
- `ParseList(text string) []string` splits lines, trims them, lowercases them and drops empty lines and duplicates;
- `Match(words, links []string, text string) *Violation` is pure and returns the first violation, words before links:
- a word or phrase matches case-insensitively between non-letter, non-digit boundaries (built from `[^\p{L}\p{N}_]`, start or end of text) with regexp.QuoteMeta;
- a link entry that is a host (example.com) matches any host in the text equal to it or ending in "." plus it: http(s) URLs, `www.` prefixes and bare domains (labels of letters, digits and hyphens with at least one dot);
- an entry with a path matches a URL whose host and path start with it.
- `NewFilter(db) *Filter`. `(*Filter).Check(ctx, text) (*Violation, error)` reads the singleton row, returns nil when Enabled is false, recompiles the matchers only when the stored text differs from the cached text (guard the cache with a sync.Mutex), and fills Message from the lang phrase for `towel.Locale(ctx)` through the framework phrasebook, falling back to the English text.
- classes/filter_smoke_test.go `TestFilterSmoke` (pure, no Docker): "Spam here" against the word spam gives a word violation; "spamming" gives none; "Kup TANIO teraz" against the phrase "kup tanio" gives a word violation; "visit https://www.bad.example/x" and "bad.example" against the link bad.example give link violations; "good.example" gives none.
- README.md in the standard plugin structure:
- H1, one-sentence summary, import line, a `**Name:**` beach mnemonic, Overview, Features, Usage, API reference, Configuration (the two keys), Dependencies, Testing;
- Usage shows a neutral host (`acme`) wiring Check into another plugin's pre-save event without importing it here.
Commit in ../sm-moderation-plugin (`feat: golem15.moderation banned word and link filter`). Do not create a remote, push or tag.</action>
<verify>
<automated>go -C ../sm-moderation-plugin vet ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes 2&gt;&amp;1 | grep -q -- '^--- PASS: TestFilterSmoke'</automated>
<fails_when>Non-zero exit: the module does not build or vet, the short suite fails, or no "--- PASS: TestFilterSmoke" line (a boundary or host rule wrong).</fails_when>
</verify>
<acceptance_criteria>
- `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0.
- `grep -c 'golem15_moderation_settings' ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go` prints at least 2.
- `grep -c 'golem15.moderation.manage_settings' ../sm-moderation-plugin/admin_permissions.go` prints at least 1.
- `grep -c '^\*\*Name:\*\*' ../sm-moderation-plugin/README.md` prints 1.
- `git -C ../sm-moderation-plugin remote` prints nothing.
</acceptance_criteria>
<done>A shared, chat-agnostic moderation plugin exists locally: admins edit banned words and links in settings, and any host can call Filter.Check from a pre-save seam.</done>
</task>
<task type="auto">
<name>Task 4: Module-mode hygiene: chatvideo, herald and userfriends require tags, and the whole family builds and tests</name>
<files>../sm-chatvideo-plugin/go.mod, ../sm-chatvideo-plugin/go.sum, ../sm-chatvideo-plugin/README.md, ../sm-herald-plugin/go.mod, ../sm-herald-plugin/go.sum, ../sm-herald-plugin/README.md, ../sm-userfriends-plugin/go.mod, ../sm-userfriends-plugin/go.sum, ../sm-userfriends-plugin/README.md</files>
<read_first>../sm-chatvideo-plugin/go.mod and go.work, ../sm-herald-plugin/go.mod and go.work, ../sm-userfriends-plugin/go.mod and go.work, ../sm-chatchannels-plugin/go.mod and ../sm-chatbots-plugin/go.mod (after the Task 1 fast-forward), ../sm-bm-app/plugins/golem15/chatbots/go.mod (the "drop sibling replaces" analog), each README's Dependencies section, 20-RESEARCH.md (Repo State and Tagging, Pitfall 2)</read_first>
<action>Per SC-1 and RESEARCH Pitfall 2. With `GOWORK=off GOPRIVATE=git.golem15.com/*`, edit each go.mod:
- sm-chatvideo-plugin: delete the sm-chat-plugin and sm-user-plugin replace lines. Require `git.golem15.com/golem15/sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`. Keep `replace git.golem15.com/golem15/summercms => ../summercms.go`.
- sm-herald-plugin: delete the sm-user-plugin replace. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
- sm-userfriends-plugin: replace the chat pseudo-version with v0.1.3. Require sm-user-plugin v0.1.13 and summercms v0.5.1.
- sm-chatchannels-plugin and sm-chatbots-plugin (fast-forwarded in Task 1): confirm their go.mod holds no replace other than the framework one and only tag requires. If either does not, fix it the same way and add it to this task's commits.
Run `go mod tidy` in each changed repo (GOWORK=off). The tracked go.work files stay: they are development conveniences and module consumers ignore them. Update each changed README's Dependencies section with the required versions and add a changelog line "v0.1.0: first tag; requires tagged chat, user and SummerCMS modules" where the README keeps a changelog.
Then verify the family:
- all eight repos (chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation) pass `go vet ./...` and `go test -count=1 -short ./...` through their go.work;
- chatchannels, chatbots, chatvideo, herald and userfriends pass `GOWORK=off go build ./...`, which resolves chat v0.1.3 and user v0.1.13 from git.golem15.com and proves no sibling path is needed.
Grep the changed files of all eight repos for consuming application names (the sm-*-app repository names next to summercms.go, and their vendor prefixes) and remove any hit in the files this plan changed.
Commit per repo by path (`chore: require tagged chat, user and SummerCMS modules; drop sibling replaces`). Do not push or tag.</action>
<verify>
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do go -C ../$r vet ./... &amp;&amp; go -C ../$r test -count=1 -short ./... || exit 1; done &amp;&amp; for r in sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-herald-plugin sm-userfriends-plugin; do GOWORK=off GOPRIVATE='git.golem15.com/*' go -C ../$r build ./... || exit 1; done</automated>
<fails_when>Non-zero exit: any repo fails vet or its short suite, or a GOWORK=off build fails (for example "unknown revision v0.0.0" or a sibling replace path that no longer resolves).</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE '^replace .*=> \.\./(sm-|fonoteka)' ../sm-chatvideo-plugin/go.mod ../sm-herald-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
- `grep -c 'v0.0.0' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 0 for each file.
- `grep -c 'sm-chat-plugin v0.1.3' ../sm-chatvideo-plugin/go.mod ../sm-userfriends-plugin/go.mod` prints 1 for each file.
- `git -C ../sm-<repo> status --porcelain` prints nothing for all eight repos after the commits.
</acceptance_criteria>
<done>Every chat-family module resolves its dependencies by tag, so a demo build without go.work can require them.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 5: Go-ahead for the one-way release: create sm-moderation-plugin on git.golem15.com, push eight repositories and tag them</name>
<read_first>The release table Claude prints before asking, one row per repository: current remote tags (`git ls-remote --tags origin`), the next free tag, the commit it will sit on, the commits it adds since the last tag, and why (seam, first tag, go.mod fix, new repo). Also whether `tea login list` shows a git.golem15.com login, and the visibility of sm-sitemap-plugin.</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY. With "release", git.golem15.com/golem15/sm-moderation-plugin exists (`git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0) before Task 6 pushes.
</acceptance_criteria>
<decision>Create the sm-moderation-plugin repository, push the commits of this plan and set the eight tags now?</decision>
<context>A pushed Go module tag is cached by module proxies and can never point anywhere else (one-way, as 19.4.1 D-03). Tags freeze exactly the commits Tasks 1-4 verified. chat v0.1.4 is a patch: the seam is additive and WavePath's parity tests stay green (D-18). Plan 02 cannot mount the chat family by tag until this runs. Creating a repository on git.golem15.com and pushing need your go-ahead (CLAUDE.md).</context>
<options>
<option id="release">
<name>Create the repository, push and tag</name>
<pros>Plan 02 can mount all eight plugins by tag; other applications can adopt the seams and the filter.</pros>
<cons>The tags are permanent; a mistake needs a new patch tag, not a moved tag.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>Nothing permanent happens.</pros>
<cons>Plan 02 and every later plan are blocked; the demo cannot require the chat family.</cons>
</option>
</options>
<instructions>For "release": if Claude reported no tea login for git.golem15.com, create the repository golem15/sm-moderation-plugin (empty, same visibility as sm-sitemap-plugin, default branch main) in the git.golem15.com web UI first, then reply. Do not paste tokens into the chat.</instructions>
<resume-signal>Reply "release" (add "repo ready" if you created the repository yourself) or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 6: Release: push, tag and prove the eight tags resolve in module mode</name>
<reversibility rating="one-way">Pushed module tags are cached by proxies and every consumer's go.sum; they can only be superseded, never moved.</reversibility>
<precondition>Task 5 resolved with "release"; `git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0 or `tea login list` lists git.golem15.com.</precondition>
<files>../sm-moderation-plugin/README.md</files>
<read_first>The Task 5 release table, each repository's README changelog, .planning/notes/core-plugins-own-repos.md</read_first>
<action>Per SC-1 and D-17/D-18.
1. If the repository does not exist yet, create it with `tea repo create --name sm-moderation-plugin --owner golem15` at sm-sitemap-plugin's visibility. Then `git -C ../sm-moderation-plugin remote add origin git@git.golem15.com:golem15/sm-moderation-plugin.git` and push main with upstream.
2. Push the default branch of chat, forum, chatvideo, herald and userfriends (and chatchannels or chatbots only if Task 4 committed there). No force-push.
3. Read the next free tag from `git ls-remote --tags origin` per repository: chat the next v0.1.x patch (v0.1.4 expected), the others v0.1.0 unless taken. Create annotated tags (`git tag -a <tag> -m "<one-line summary>"`) on the HEADs Tasks 1-4 verified, chat first. Push each tag. Confirm `git ls-remote origin refs/tags/<tag>^{}` equals the local HEAD.
4. If the moderation README says no tag exists yet, fix it in a commit before tagging that repository, never after.
5. Module-mode proof: in a fresh `mktemp -d` directory, `go mod init example.invalid/familycheck`, require all eight modules at their new tags plus `git.golem15.com/golem15/summercms v0.5.1`, write a main.go that blank-imports the eight plugin packages, then run `GOWORK=off GOPRIVATE=git.golem15.com/* GOFLAGS=-mod=mod go build ./...`. Delete the directory afterwards. The SUMMARY records every tag with its commit hash for plan 02.</action>
<verify>
<automated>for r in sm-chat-plugin sm-chatchannels-plugin sm-chatbots-plugin sm-chatvideo-plugin sm-forum-plugin sm-herald-plugin sm-userfriends-plugin sm-moderation-plugin; do t=$(git -C ../$r describe --tags --exact-match HEAD) &amp;&amp; [ "$(git -C ../$r ls-remote origin "refs/tags/$t^{}" | cut -f1)" = "$(git -C ../$r rev-parse HEAD)" ] || exit 1; done</automated>
<fails_when>Non-zero exit: a repository HEAD carries no tag (describe fails), or the remote tag is missing or points at a different commit than the local HEAD.</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-chat-plugin describe --tags --exact-match HEAD` prints a v0.1.x tag at least v0.1.4.
- The scratch module build printed no error, and the SUMMARY lists the eight tags with commit hashes.
- `git -C ../sm-<repo> status --porcelain` prints nothing in all eight repositories.
</acceptance_criteria>
<done>All eight chat-family modules are on git.golem15.com at verified tags and resolve together without go.work.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| chat/forum HTTP clients to the plugin APIs | message and post bodies are untrusted input; the seam runs on them before storage |
| host listeners to plugin services | host code decides refusals; a listener error must not leak internals |
| admin settings to the filter | administrators edit the lists that every message is matched against |
| build machine to git.golem15.com and module proxies | tags become immutable inputs of every consumer |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-01 | Tampering | chat write paths that skip the seam | high | mitigate | MessageSaving fires in SendMessageWith (SendMessage, replies and ReplyTo funnel into it) and EditMessage; TestMessageSavingTracer covers send, reply and edit; plan 06 adds the full matrix |
| T-20-02 | Information disclosure | listener errors surfacing in API bodies | medium | mitigate | only contracts.Rejection maps to 422 with its own Field and Message; any other listener error keeps the existing opaque 500 path of writeChatError and writeForumError |
| T-20-03 | Denial of service | filter matching cost | medium | mitigate | RE2 linear-time regexps from QuoteMeta'd entries; at most 500 entries of at most 200 characters per list (BeforeValidate); matchers compiled once per list version |
| T-20-04 | Elevation of privilege | moderation settings screen | medium | mitigate | settings item behind the permission golem15.moderation.manage_settings, granted to the same role as sitemap settings |
| T-20-05 | Tampering | one-way module tags | medium | mitigate | tags only on HEADs that passed vet and tests in Tasks 1-4; next free number read from the remote; go-ahead at Task 5; ls-remote check and a GOWORK=off scratch build after pushing |
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install: Go modules are the project's own tagged repositories plus gorm and gormigrate at versions already in sm-sitemap-plugin's go.sum (GOPRIVATE, go.sum committed) |
</threat_model>
<verification>
- Task 1: chat seam tracer green, chat parity tests unchanged, seven siblings at origin HEAD.
- Task 2: forum seam tracer green, forum parity tests unchanged.
- Task 3: moderation plugin builds, filter smoke green, no dependency on chat or forum.
- Task 4: all eight repositories vet and test; five chat dependants build with GOWORK=off.
- Task 5: go-ahead recorded.
- Task 6: eight tags on origin equal to the verified HEADs; the scratch module builds them together.
</verification>
<success_criteria>
The chat family and the moderation plugin are released by tag (SC-1 prerequisite), with the D-18 seams additive and parity-safe and the D-17 filter shared and chat-agnostic.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md` when done: per repository the before and after HEAD, the tag and its commit, the go.mod changes, how the moderation repository was created, and the checkpoint answer.
</output>

View File

@@ -0,0 +1,214 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 01
subsystem: plugins
tags: [chat, forum, moderation, go-modules, release, gitea]
requires:
- phase: 19.4.1-early-public-demo-on-demo-summercms-io
provides: live demo instance the chat family is added to
- phase: 34
provides: summercms v0.6.0 framework tag the family requires
provides:
- "sm-chat-plugin v0.1.5: events.MessageSaving pre-save event + contracts.Rejection (422 on refusal)"
- "sm-forum-plugin v0.1.1: events.PostSaving pre-save event + contracts.Rejection (422 on refusal)"
- "sm-moderation-plugin v0.1.0 (new public repo): golem15.moderation, *classes.Filter for banned words and links, admin settings screen"
- "sm-chatvideo-plugin, sm-herald-plugin, sm-userfriends-plugin v0.1.1: go.mod on tagged chat/user/summercms, framework replace only"
- "Eight chat-family module tags that resolve together with GOWORK=off against summercms v0.6.0"
affects: [20-02, 20-03, 20-04, 20-05, 20-06, sm-demo-app]
actuals:
tokens: 32150 # chars/4 over the realized diffs in the six changed sibling repos
tasks: 6
commits: 7 # measured: git rev-list over the sibling repos (chat 2, forum 1, moderation 1, chatvideo 1, herald 1, userfriends 1); summercms.go had 0 code commits
plan_head_before: 677e30f6f53e9eab79d6cadd46b8b6e2843f5c61 # summercms.go; multi-repo plan, per-repo bases listed in the body
plan_head_after: 677e30f6f53e9eab79d6cadd46b8b6e2843f5c61 # summercms.go before this SUMMARY commit
tech-stack:
added: []
patterns:
- "Pre-save veto event: plugin fires <Thing>Saving on the bus before the transaction; a listener returning contracts.Rejection{Field, Message} maps to the existing 422 validation envelope, any other error keeps the opaque 500 path"
- "Shared filter published as a typed service (app.Publish(*classes.Filter)) that consumers look up; the filter plugin imports none of its consumers"
key-files:
created:
- ../sm-moderation-plugin/plugin.go
- ../sm-moderation-plugin/classes/filter.go
- ../sm-moderation-plugin/models/settings.go
- ../sm-moderation-plugin/admin.go
- ../sm-chat-plugin/contracts/rejection.go
- ../sm-forum-plugin/contracts/rejection.go
modified:
- ../sm-chat-plugin/events/events.go
- ../sm-chat-plugin/classes/service.go
- ../sm-chat-plugin/api/errors.go
- ../sm-forum-plugin/events/events.go
- ../sm-forum-plugin/classes/forum_service.go
- ../sm-forum-plugin/api/errors.go
- ../sm-chatvideo-plugin/go.mod
- ../sm-herald-plugin/go.mod
- ../sm-userfriends-plugin/go.mod
key-decisions:
- "Task 5 checkpoint answered \"release\" by the user (2026-10-30): create sm-moderation-plugin, push and tag"
- "Tag numbers moved up one because the marketplace manifest commits had already tagged chat v0.1.4 and the others v0.1.0; chat v0.1.5, forum/chatvideo/herald/userfriends v0.1.1, moderation v0.1.0"
- "The family requires summercms v0.6.0, not the planned v0.5.1"
- "sm-moderation-plugin is public, like sm-sitemap-plugin, default branch main"
patterns-established:
- "Pre-save veto seam: events.<Thing>Saving + contracts.Rejection, additive and parity-safe"
requirements-completed: [SC-1, D-17, D-18, D-19, D-20]
coverage:
- id: D1
description: "Chat MessageSaving seam: a Rejection from a listener answers 422 on send, reply and edit, with no row written and no event or publish"
requirement: "D-18"
verification:
- kind: integration
ref: "../sm-chat-plugin/saving_smoke_test.go#TestMessageSavingTracer"
status: pass
human_judgment: false
- id: D2
description: "Forum PostSaving seam: CreateThread, Reply and EditPost fire PostSaving before render; a Rejection maps to 422 under title or body"
requirement: "D-18"
verification:
- kind: integration
ref: "../sm-forum-plugin/saving_smoke_test.go"
status: pass
human_judgment: false
- id: D3
description: "golem15.moderation Filter flags banned words and phrases (Unicode boundaries, Polish letters) and banned links (domain, subdomains, URL prefixes), nil for clean text"
requirement: "D-17"
verification:
- kind: unit
ref: "../sm-moderation-plugin/classes/filter_smoke_test.go#TestFilterSmoke"
status: pass
human_judgment: false
- id: D4
description: "Eight chat-family tags pushed to git.golem15.com on the verified HEADs and resolving together in module mode"
requirement: "SC-1"
verification:
- kind: other
ref: "Task 6 verify loop: git describe --tags --exact-match HEAD == ls-remote refs/tags/<tag>^{} for all eight repos (exit 0)"
status: pass
- kind: other
ref: "scratch module, GOWORK=off GOPRIVATE='git.golem15.com/*' GOFLAGS=-mod=mod go build ./... (exit 0)"
status: pass
human_judgment: false
duration: 3min (Task 6 continuation; Tasks 1-5 ran in earlier sessions)
completed: 2026-10-30
status: complete
---
# Phase 20 Plan 01: Chat Family Release Summary
**Chat and forum gained additive pre-save veto events (MessageSaving, PostSaving with contracts.Rejection → 422). The new shared golem15.moderation plugin publishes a banned word and link filter. All eight chat-family modules are tagged on git.golem15.com and build together with GOWORK=off against summercms v0.6.0.**
## Performance
- **Duration:** about 3 min for the Task 6 continuation (17:22–17:25 UTC); Tasks 1-5 ran in earlier sessions
- **Started (continuation):** 2026-10-30T17:22:07Z
- **Completed:** 2026-10-30T17:25Z
- **Tasks:** 6/6 (Task 5 was the go-ahead decision)
- **Files modified:** 45 across six sibling repos (chat 7, forum 7, chatvideo 3, herald 3, userfriends 4, moderation 21 new)
## Accomplishments
- `sm-chat-plugin` v0.1.5: `events.MessageSaving` fires in `SendMessageWith` (send, reply, ReplyTo) and `EditMessage` before the transaction. `writeChatError` maps a `contracts.Rejection` to the existing 422 `{"error":"Validation failed","errors":{field:[msg]}}`. With no listener the behaviour is unchanged, and the parity and golden tests pass without edits.
- `sm-forum-plugin` v0.1.1: `events.PostSaving` fires in `CreateThread`, `Reply` and `EditPost` before `breakwater.Render`. A Rejection lands under `errors.title` or `errors.body`.
- `sm-moderation-plugin` v0.1.0 (new repo): `golem15.moderation` publishes `*classes.Filter` at boot. The lists come from the `banned_words` and `banned_links` textareas on its settings screen (singleton `golem15_moderation_settings`, permission `golem15.moderation.manage_settings`). It imports neither chat nor forum.
- chatvideo, herald and userfriends v0.1.1: each go.mod now keeps only the framework replace and requires tagged chat/user/summercms modules.
- Module-mode proof: a scratch module that requires the eight tags and summercms v0.6.0 and blank-imports all eight plugin packages built with `GOWORK=off`. It resolved sm-user-plugin v0.1.13 transitively.
## Release table (for plan 02)
| Repo | Before HEAD (origin) | After HEAD | Tag | Tag commit | Why |
|------|----------------------|------------|-----|------------|-----|
| sm-chat-plugin | db3b518 | 6275ae4 | v0.1.5 | 6275ae4c7cd80fdc5b0c07466ba65ca63b518503 | MessageSaving seam (patch) |
| sm-chatchannels-plugin | 017843a | 017843a | v0.1.0 (existing) | 017843a | no new commits, not pushed or re-tagged |
| sm-chatbots-plugin | 24eadf6 | 24eadf6 | v0.1.0 (existing) | 24eadf6 | no new commits, not pushed or re-tagged |
| sm-chatvideo-plugin | 33ff4f5 | 0f08c35 | v0.1.1 | 0f08c35380d7505d591cef96ac80ef8d7649e70e | go.mod hygiene |
| sm-forum-plugin | 36bd875 | 4e8a3c3 | v0.1.1 | 4e8a3c35e20698eaf8603ce68410ce7f02bc2674 | PostSaving seam |
| sm-herald-plugin | db8a1bb | 82f070a | v0.1.1 | 82f070ac89dbd1a081aee282f121f1e186a65465 | go.mod hygiene |
| sm-userfriends-plugin | 2f29b26 | 1c28db8 | v0.1.1 | 1c28db8a08a819290591a4e0365a751ebf8c913f | go.mod hygiene + plugin.yaml |
| sm-moderation-plugin | (new) | d9791e2 | v0.1.0 | d9791e2ea53bcb7abbfbca65cfc79d817ad5f33b | first release |
All tags are annotated. Each was pushed separately, and `git ls-remote origin refs/tags/<tag>^{}` equals the local HEAD in every repo. All branch pushes were fast-forwards with no force. All eight working trees are clean.
## Task Commits
1. **Task 1: chat MessageSaving seam**: `42f64eb` (feat), plus `6275ae4` (chore: no host application named in the CanDeleteMessage comment), in sm-chat-plugin
2. **Task 2: forum PostSaving seam**: `4e8a3c3` (feat), sm-forum-plugin
3. **Task 3: golem15.moderation plugin**: `d9791e2` (feat), sm-moderation-plugin
4. **Task 4: go.mod hygiene**: sm-chatvideo-plugin `0f08c35`, sm-herald-plugin `82f070a`, sm-userfriends-plugin `1c28db8` (chore)
5. **Task 5: go-ahead**: the user answered **"release"**
6. **Task 6: release**: no code commit; repo created, five branches and moderation main pushed, six tags pushed, module-mode build proven
## go.mod changes (Task 4)
- **sm-chatvideo-plugin:** dropped `replace sm-chat-plugin => ../sm-chat-plugin` and `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user`; sm-user-plugin v0.1.2 → v0.1.13; keeps sm-chat-plugin v0.1.3 and summercms v0.6.0.
- **sm-herald-plugin:** dropped `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user`; sm-user-plugin v0.1.1 → v0.1.13; summercms v0.6.0.
- **sm-userfriends-plugin:** sm-chat-plugin pseudo-version `v0.0.0-20261017200243-4d0e764ca443` → v0.1.3; sm-user-plugin v0.1.1 → v0.1.13; summercms v0.6.0.
- **sm-moderation-plugin:** requires summercms v0.6.0.
- Every one of them keeps only `replace git.golem15.com/golem15/summercms => ../summercms.go`.
## How the moderation repository was created
`tea login list` showed a git.golem15.com login (user jakub). The API reported sm-sitemap-plugin as `"private":false`, default branch `main`. The repo was created with `tea repo create --login git.golem15.com --name sm-moderation-plugin --owner golem15` as a public repo (no `--private`), and the API confirmed `private:false`, `default_branch:main`. Then `git -C ../sm-moderation-plugin remote add origin git@git.golem15.com:golem15/sm-moderation-plugin.git` and `git push -u origin main`. `git ls-remote git@git.golem15.com:golem15/sm-moderation-plugin.git` exits 0 and shows d9791e2 on `refs/heads/main`. The local repo had been created in Task 3 with `git init -b main`.
## Decisions Made
- Checkpoint answer: **"release"** (user, 2026-10-30).
- The family requires summercms v0.6.0, the current framework tag, instead of the v0.5.1 the plan named.
- chatchannels and chatbots keep their existing v0.1.0 tags on HEAD. They had no new commits, so they were neither pushed nor re-tagged.
## Deviations from Plan
1. **Tag numbers moved up one.** Another session's marketplace manifest commits had already tagged sm-chat-plugin v0.1.4 and the other repos v0.1.0. The new tags are therefore chat v0.1.5 and forum/chatvideo/herald/userfriends v0.1.1. Moderation is v0.1.0 as planned, and chatchannels and chatbots stay on their existing v0.1.0. The tags were read from `git ls-remote --tags origin` immediately before tagging.
2. **summercms stays v0.6.0** in every go.mod and in the scratch proof, not v0.5.1.
3. **[Rule 2] Moderation got `plugin.yaml` and `manifest_test.go`** (`TestPluginManifest`), so the plugin matches the marketplace manifest convention that landed during the plan.
4. **[Rule 3] userfriends `plugin.yaml` regenerated** because the go.mod change made the manifest check fail (commit 1c28db8).
5. **No changelog lines for chatvideo, herald and userfriends.** Their READMEs have no changelog section.
6. **App names removed** from a chat comment (`6275ae4`) and from the chat README, per the "framework code never names a consuming application" rule.
7. **Added `(*Filter).SetTranslator`** to the moderation filter, so violation messages are localised through the host's translator.
8. **Extra moderation exports** beyond the plan's list: `models.SetLimits`, `models.Limits`, `models.Lines`, `models.LinkEntry`, `models.ValidHost`, `models.DefaultSettings`, `models.Text`; root `PluginID`, `ConfigMaxEntries`, `ConfigMaxEntryLength`, `PermissionManageSettings`.
9. **Task 4 acceptance check `grep -c v0.0.0` fails as written.** It matches only third-party `// indirect` pseudo-versions. No golem15 module is on a pseudo-version any more.
**Total deviations:** 9 (2 auto-fixed under Rules 2/3, the rest are renumbering, scope notes and documentation)
**Impact on plan:** None on the goal. The seams stay additive, and plan 02 can mount all eight by tag.
## Follow-ups left alone (out of scope)
- The tracked `go.work` files in the sibling repos still point at `../fonoteka.go/plugins/golem15/user`. They only affect workspace builds, not module mode.
- The forum and herald READMEs still say "v0.4.0 modules" in their Dependencies section.
- `../sm-chat-plugin/classes/kind_test.go` is not gofmt'd. This predates the plan.
## Issues Encountered
None in Task 6. Every push, tag push and ls-remote check succeeded on the first attempt. The proxy had not yet seen the new tags, and `GOPRIVATE='git.golem15.com/*'` made go fetch them directly.
## Threat notes
- T-20-03 (filter cost): one uncached `Match` at 2 lists × 500 entries × 200 characters takes about 36 ms, and the compiled matchers take about 8.8 KB. Matchers are compiled once per list version, so this cost is paid once per settings change, not per message.
- T-20-05 (one-way tags): the tags sit on the HEADs that passed vet and tests in Tasks 1-4. They were confirmed free immediately before creation and verified on the remote afterwards.
## User Setup Required
None.
## Next Phase Readiness
- Plan 02 can require the chat family by tag: chat v0.1.5, chatchannels v0.1.0, chatbots v0.1.0, chatvideo v0.1.1, forum v0.1.1, herald v0.1.1, userfriends v0.1.1, moderation v0.1.0, with summercms v0.6.0 and sm-user-plugin v0.1.13.
- Plugin release posts on summercms.io/changelog/plugins for the six new tags are not written yet. Per CLAUDE.md, plugin tags get a post.
---
*Phase: 20-demo-instance-with-every-shared-plugin*
*Completed: 2026-10-30*
## Self-Check: PASSED
- All eight repos: `git describe --tags --exact-match HEAD` matches `ls-remote refs/tags/<tag>^{}` (verify loop exit 0).
- Commits 42f64eb, 6275ae4, 4e8a3c3, d9791e2, 0f08c35, 82f070a, 1c28db8 exist on origin/main of their repos.
- `../sm-moderation-plugin/classes/filter.go`, `../sm-chat-plugin/contracts/rejection.go` and `../sm-forum-plugin/contracts/rejection.go` exist.

View File

@@ -0,0 +1,400 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 02
type: execute
wave: 2
depends_on: ["20-01"]
files_modified:
- ../sm-demo-app/.gitmodules
- ../sm-demo-app/go.work
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/plugins.gen.go
- ../sm-demo-app/plugins/golem15/oauthserver
- ../sm-demo-app/plugins/golem15/chat
- ../sm-demo-app/plugins/golem15/chatchannels
- ../sm-demo-app/plugins/golem15/chatbots
- ../sm-demo-app/plugins/golem15/chatvideo
- ../sm-demo-app/plugins/golem15/forum
- ../sm-demo-app/plugins/golem15/herald
- ../sm-demo-app/plugins/golem15/userfriends
- ../sm-demo-app/plugins/golem15/moderation
- ../sm-demo-app/config/golem15.chatvideo.yaml
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/plugins/demo/config/config.yaml
- ../sm-demo-app/plugins/demo/chat.go
- ../sm-demo-app/plugins/demo/chat_api.go
- ../sm-demo-app/plugins/demo/chatbots.go
- ../sm-demo-app/plugins/demo/moderation.go
- ../sm-demo-app/plugins/demo/realtime_access.go
- ../sm-demo-app/plugins/demo/routes.go
- ../sm-demo-app/plugins/demo/classes/chat_context.go
- ../sm-demo-app/plugins/demo/classes/forum_access.go
- ../sm-demo-app/plugins/demo/console/seed.go
- ../sm-demo-app/plugins/demo/console/seed_chat.go
- ../sm-demo-app/plugins/demo/console/seed_forum.go
- ../sm-demo-app/plugins/demo/console/seed_friends.go
- ../sm-demo-app/plugins/demo/console/reset.go
- ../sm-demo-app/chat_smoke_test.go
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/deploy/centrifugo.json.example
- ../sm-demo-app/deploy/livekit.yaml.example
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/README.md
- .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
- .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
autonomous: true
requirements: [SC-1, D-05, D-06, D-07, D-08, D-09, D-10, D-12, D-14, D-17, D-20, D-22, D-23]
estimate:
tokens: 320000
raw_tokens: 320000
tasks: 4
confidence: low
must_haves:
truths:
- "SC-1: sm-demo-app requires chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation at the plan 01 tags, mounts them as submodules at plugins/golem15/<name> checked out on those tags, lists them in summer.yaml before golem15.demo, and passes TestGoModRequiresTags, TestBootAllPlugins and scripts/check-pins.sh; scripts/build.sh dev builds."
- "Folded todo: plugins/golem15/oauthserver sits on tag v0.1.3 (which contains b6a9586), go.mod requires sm-oauthserver-plugin v0.1.3, the change is one commit `chore: bump sm-oauthserver-plugin to v0.1.3`, and the todo file moves to .planning/todos/done with g15office task ssuyq2os set to Live."
- "D-06: golem15.demo registers the chat context alias demo (id 1, broadcast chat:context:1) whose members are every activated, non-deleted, non-banned user, and the alias channel (chatchannels' ChannelAlias) that resolves a seeded channel's backing conversation to the same membership with broadcast chat:channel:{id}; both require live membership."
- "D-07: channels exist only through demo:seed; the chatchannels access resolver answers CanManageContext false, so POST /_chatchannels/api/v1/contexts/demo/1/channels is refused for every member; a member can join and leave seeded channels and post in them."
- "D-08: any member can open a DM with any other member through POST /_chat/api/v1/contexts/demo/1/dm; userfriends does not gate it."
- "D-09: demo:seed creates persona users (no usable password) with EN and PL channel history, threads, reactions and pins, forum categories, threads and posts in EN and PL, friendships and a friend group, the demo-moderators user group and default moderation lists; a second run reports exists; demo:reset wipes every chat-family table in foreign-key order and reseeds, keeping golem15_moderation_settings and the userfriends context singleton."
- "D-17: a banned word or link in a chat message (create, reply, edit) or a forum thread, reply or edit is refused with 422 through the demo's listeners on events.MessageSaving and events.PostSaving calling the published moderation Filter."
- "D-22: members of the user group named by golem15.demo.chat.moderator_group (default demo-moderators) can delete other members' messages in channel conversations (CanModerateMessage) and manage forum posts (forum CanManage); the bootstrap reports moderator true for them; banning stays in the user admin."
- "D-05: chatbots lists the demo's ephemeral slash commands in GET /_chatbots/api/v1/commands and POST /_chatbots/api/v1/dispatch answers them in ephemeral mode; there is no bot persona."
- "D-12, D-23: config/golem15.chatvideo.yaml sets max_duration 900, max_participants 4, token_ttl 900 and screen_share false; the server does not restrict calls by conversation type (calls are limited to DMs by the UI, plan 04)."
- "GET /_demo/api/v1/chat answers a member with {member, moderator, context, channels[], conversations[], limits} and GET /_demo/api/v1/chat/members with {members[]}; both need a JWT."
- "GET /api/realtime/token answers 200 for any member (guard demo.realtime-access) and 401 without a JWT; Centrifugo's committed config has the namespaces presentations, chat, user and video with the subscribe proxy; scripts/dev.sh up starts a local LiveKit on 127.0.0.1:7890 (TCP 7891, UDP 7892) with keys written to .env only (D-10 locally)."
- "D-14: the forum resolver lets every member read and post in context demo/1 and the seeded EN and PL categories are listed by GET /_forum/api/v1/contexts/demo/1/categories."
artifacts:
- path: "../sm-demo-app/plugins/demo/classes/chat_context.go"
provides: "DemoContext, ChannelContext adapter, ChatAccess resolver, membership query"
contains: "ChatContextType"
- path: "../sm-demo-app/plugins/demo/chat.go"
provides: "registerChat: registry aliases demo and channel, resolvers, limits"
contains: "ChannelAlias"
- path: "../sm-demo-app/plugins/demo/chat_api.go"
provides: "GET /_demo/api/v1/chat and /chat/members (host bootstrap contract)"
contains: "/chat/members"
- path: "../sm-demo-app/plugins/demo/moderation.go"
provides: "MessageSaving and PostSaving listeners calling the moderation Filter"
contains: "MessageSaving"
- path: "../sm-demo-app/plugins/demo/realtime_access.go"
provides: "demo.realtime-access middleware"
contains: "demo.realtime-access"
- path: "../sm-demo-app/plugins/demo/console/seed_chat.go"
provides: "persona users, channels, messages, threads, reactions, pins (EN/PL)"
contains: "persona"
- path: "../sm-demo-app/deploy/livekit.yaml.example"
provides: "LiveKit config template: port 7890, tcp 7891, udp mux 7892, room caps"
contains: "udp_port: 7892"
- path: "../sm-demo-app/chat_smoke_test.go"
provides: "TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke"
contains: "TestDemoChatTracer"
key_links:
- from: "../sm-demo-app/plugins/demo/chat.go"
to: "../sm-demo-app/plugins/golem15/chat/contracts/registry.go"
via: "reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, ...})"
pattern: "Alias: +chanmodels\\.ChannelAlias"
- from: "../sm-demo-app/plugins/demo/moderation.go"
to: "../sm-demo-app/plugins/golem15/moderation/classes/filter.go"
via: "app.Lookup[*modclasses.Filter]() then Check(ctx, content)"
pattern: "Check\\(ctx"
- from: "../sm-demo-app/plugins/demo/routes.go"
to: "../sm-demo-app/plugins/demo/realtime_access.go"
via: "lighthouse.Mount UserAuth surf.Use(\"jwt.auth\", \"demo.realtime-access\", bucketRealtime)"
pattern: "demo\\.realtime-access"
- from: "../sm-demo-app/plugins/demo/console/reset.go"
to: "../sm-demo-app/plugins/demo/console/seed_chat.go"
via: "resetTables lists the chat-family tables before users; runSeed reseeds them"
pattern: "golem15_chat_messages"
prohibitions:
- statement: "No file inside a plugin submodule is edited from sm-demo-app; shared plugin fixes go to the plugin repos (plan 01 pattern) and need a new tag."
status: unresolved
verification: judgment
- statement: "go.mod carries no replace directive and no pseudo-version for a golem15 module."
status: unresolved
verification: automated
- statement: "No LiveKit key, VAPID key or persona password appears in a committed file or in command output; .env holds them."
status: unresolved
verification: judgment
- statement: "Nothing is pushed in this plan (plan 05 pushes with the deploy go-ahead)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): `sm-demo-app` runs every shared Golem15 plugin in one binary, and its public Nuxt site shows them working; this phase adds the chat family to the live demo from Phase 19.4.1.
This plan's slice (plan 2 of 6, wave 2, per D-20): the demo backend. After it, a registered demo member can read and post in seeded channels, DM anyone, use slash commands, read and post in the forum and manage friends through the plugins' APIs. Moderation refuses banned content, and a local LiveKit can mint calls. Nothing in the Nuxt site changes yet (plans 03 and 04).
## Decisions taken in this plan (Claude's discretion, recorded)
- **Contexts (D-06):** alias `demo`, id 1, group name "General", broadcast `chat:context:1`. Alias `channel` (chatchannels' `models.ChannelAlias`, legacy class `models.ChannelMorphName`) loads the channel by id, with broadcast `chat:channel:{id}`. Neither broadcast is subscribable, because AuthorizeChat admits only `chat:conversation:{id}`. Both implement `contracts.LiveMembership` (true) and `contracts.MessageModerator` (moderator group, non-DM conversations only), as bm does.
- **Joining a channel (RESEARCH assumption A3):** the layer opens a channel with `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, whose GetGroupChat lazy-joins a context member. The tracer test proves it. If it does not join, the bootstrap adds the caller as participant of each joined channel with chat's `AddUserToGroupChat`, and the SUMMARY records which path holds.
- **Personas (discretion):** four, Ada and Ben (English) and Ola and Tomek (Polish). Emails are `<name>@personas.demo.invalid` (RFC 2606, undeliverable). Each gets a random bcrypt hash that is never printed, so nobody can log in as a persona. Channels: `general` (#general), `announcements` (#announcements), `ogolny` (#ogólny, Polish). About 15 to 25 messages per language with two threads, reactions and one pin per channel. Persona Ada sends a welcome DM to the visitor account when golem15.demo.visitor.email is set.
- **Seeding writes:** raw SQL in the existing seed style (`insertID`, `exists`), or a ChannelService instance built with a seed-only resolver and no bus or realtime, for the channel rows. The demo's own resolver refuses CanManageContext (RESEARCH Anti-Patterns), so it cannot seed. Forum posts store content_html from `breakwater.Render`, the plugin's own renderer.
- **Moderator group (D-22):** user group code `demo-moderators` (config `golem15.demo.chat.moderator_group`), created by the seed with no members. demo:reset wipes memberships with the users, so moderators are reassigned after a reset.
- **Slash commands (D-05 discretion):** `/demo` explains what the demo shows and `/tour` lists the site sections with their paths. Both are ephemeral, English and Polish chosen from the actor's locale when known (else English). `/help` is chatbots' built-in.
- **Bootstrap contract:** bm's `/_bm/api/v1/chat` shape with `general` replaced by `channels[]`. Each channel is `{id, slug, name, conversation_id, joined, unread, mentions}`. vue-chat-layer documents this contract under a neutral name (plan 03).
- **Local LiveKit (D-10 locally):** ports 7890/7891/7892, the same as rome (RESEARCH Pitfall 7), so dev and production configs differ only in keys and external IP. `livekit_url` locally is `ws://127.0.0.1:7890`.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- Submodules `plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends,moderation}`; summer.yaml entries; go.mod requires at the plan 01 tags; sm-oauthserver-plugin v0.1.3.
- golem15.demo: `classes.DemoContext`, `classes.ChannelContext`, `classes.ChatAccess`, `classes.ForumAccess`, `classes.ChatContextType = "demo"`, `classes.ChatContextID = 1`, `classes.ChatBroadcastChannel = "chat:context:1"`, `classes.ChannelBroadcastChannel(id uint) string`, `classes.MemberIDs`; `(*Plugin).registerChat`; `MiddlewareRealtimeAccess = "demo.realtime-access"` with `pact.HasMiddleware`; routes `GET /_demo/api/v1/chat` and `GET /_demo/api/v1/chat/members`; slash commands `/demo` and `/tour`; seam listeners for `chatevents.MessageSaving` and `forumevents.PostSaving`.
- Config keys: `golem15.demo.chat.moderator_group` (default `demo-moderators`), `golem15.demo.chat.max_embeds` (default 3); `config/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`, `max_duration: 900`, `max_participants: 4`, `token_ttl: 900`, `screen_share: false`).
- demo:seed steps: `chat personas`, `chat channels and history (en)`, `chat channels and history (pl)`, `forum categories and threads (en, pl)`, `friends and group`, `moderator group demo-moderators`, `moderation lists`; demo:reset table list extended.
- Deploy and dev: `deploy/livekit.yaml.example`, Centrifugo namespaces `chat`, `user`, `video`; `scripts/dev.sh` LiveKit start and stop (pid name `livekit`); env keys `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY` and `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET` (names only in env.example).
- Smoke tests: `TestDemoChatTracer`, `TestDemoModerationSmoke`, `TestSeedChatSmoke` (chat_smoke_test.go). Full unit tests come in plan 06.
<objective>
Mount the released chat family in sm-demo-app and give it a demo host: community context, channel adapter, resolvers, bootstrap endpoints, realtime for every member, moderation wiring, slash commands, EN/PL showcase seed with reset, and a local LiveKit.
Purpose: SC-1 (builds with every shared plugin; demo:seed fills each), D-05 to D-09, D-12, D-14, D-17, D-22, D-23 on the server side, and the folded oauthserver todo. Plans 03 and 04 build the site UI on top of these endpoints.
Output: commits in ../sm-demo-app (code) and one planning commit in summercms.go (the todo move). Nothing pushed.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
@.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
@.planning/G15OFFICE.md
Repo: ../sm-demo-app (all code edits). Read-only: the mounted plugin submodules, ../sm-bm-app/plugins/jz/bm (host analog), ../sm-bm-app/deploy/centrifugo/bm-config.json (namespace options).
Rules: no co-author tags; stage by explicit path; one `feat: mount golem15.<id> <tag>` commit per plugin (precedent 9451b31: submodule, .gitmodules, go.work, go.mod, go.sum, summer.yaml, plugins.gen.go, boot_test.go); planning files in their own commit in summercms.go. Other sessions (Phase 33) may have uncommitted edits in sm-demo-app or vue-demo-app: check `git status --porcelain` before each commit, never stage or revert files this plan did not change, and stop if a file this plan must change carries foreign edits. Verify commands run from the framework root with `go -C ../sm-demo-app` and scripts by path (they cd to their own root). Docker tests: if the socket is denied, run through `sudo -u golem`.
<interfaces>
Host analog ../sm-bm-app/plugins/jz/bm:
- chat.go: `registerChat` (HasPlugin guard, `app.Lookup[*chatcontracts.Registry]()`, `reg.Add(p.ID(), chatcontracts.Registration{Alias, Loader})`, `lagoon.OnDatabase` then `app.Lookup[*chanclasses.ChannelService]()` and `SetAccessResolver`; `installChatLimits` via `svc.SetMaxMessageBytes`).
- classes/chat_context.go: assertions `chatcontracts.Context`, `MessageModerator`, `LiveMembership`, `chancontracts.AccessResolver`; methods ID, Type, ChatEnabled, GroupName, BroadcastChannel, CanActFor (`chatcontracts.DefaultCanActFor`), CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members; `ChatContextLoader`; member query joins users_groups and user_groups.
- realtime_access.go: `MiddlewareRealtimeAccess`, `Middlewares() map[string]pact.Middleware`, `realtimeAccess`, `writeRealtimeGuard`.
- controllers/api/chat.go: bootstrap JSON structs (member, moderator, context, general, conversations, limits; members with id, name, surname, handle, is_bot).
chatchannels: `models.ChannelAlias = "channel"`, `models.ChannelMorphName`, `models.Channel{ContextType, Name, Slug, Visibility...}`; `chancontracts.AccessResolver{CanAccessContext, CanManageContext}`; `ChannelService.CreateChannel` gated by CanManageContext (channel_service.go:136); `JoinChannel` only upserts a pref row (:959).
chat: `Service.GetGroupChat(ctx, actorID, alias, contextID)`, `AddUserToGroupChat(ctx, userID, alias, contextID)`, `GetConversationsForUser`, `GetAvailableDmRecipients`, `SetMaxMessageBytes`, `MaxMessageBytes`; events `MessageSaving` and `contracts.Rejection` (plan 01).
forum: `forum.Plugin.SetAccessResolver`, `contracts.AccessResolver` (CanRead, CanPost, CanManage, CanManageContext), `ForumService.RegisterContext(alias, legacyPHPClass)`, events `PostSaving`, `contracts.Rejection` (plan 01).
chatbots: `contracts.SlashCommand{Trigger, Description, IsAvailable, Handle}`, `classes.Ephemeral(payload)`, `(*classes.Registry).Register` (help is reserved), Registry published in chatbots' OnDatabase hook.
moderation: `*classes.Filter` published at boot, `Check(ctx, text) (*Violation, error)` (plan 01).
chatvideo config keys (config/config.yaml of the plugin): livekit_url, livekit_api_key, livekit_api_secret, max_duration, token_ttl, livekit_admin_url, max_participants, screen_share; env `SUMMER_GOLEM15__CHATVIDEO__*` or PHP names `LIVEKIT_*`.
sm-demo-app today: plugins/demo/plugin.go (Requires, Boot calls wireRealtime), routes.go (`APIPrefix`, `mountRealtime` limited to presentations with `presentations.realtime-owner`), console/seed.go (`runSeed` step table, `created`, `insertID`, `exists`), console/reset.go (`resetTables`, children first), boot_test.go (`wantPlugins`, routed prefixes), realtime_test.go (`TestRealtimeRoutes`, `wantNamespaces`), testmain_test.go (`bootDemo`), go_mod_test.go, scripts/dev.sh (`ensure_env`, `start_centrifugo`, `claim_port`, `up`/`down`/`status`), deploy/centrifugo.json.example (namespaces array with `subscribe_proxy_enabled`).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a registered member reads the chat bootstrap and posts in the seeded #general channel through the chat API</name>
<precondition>The plan 01 SUMMARY lists the eight tags and `git ls-remote --tags git@git.golem15.com:golem15/sm-chat-plugin.git` shows the chat tag; `git -C ../sm-demo-app status --porcelain` shows nothing except ` M plugins/golem15/oauthserver`; `docker info` exits 0.</precondition>
<files>../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chat, ../sm-demo-app/plugins/golem15/chatchannels, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/classes/chat_context.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go, .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md, .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md</files>
<read_first>../sm-demo-app/summer.yaml, go.mod, go.work, .gitmodules, plugins.gen.go, `git -C ../sm-demo-app show 9451b31 --stat` (mount commit shape), ../sm-demo-app/plugins/demo/plugin.go, routes.go, console/seed.go, ../sm-demo-app/boot_test.go, testmain_test.go, internal/demotest/postgres.go, ../sm-bm-app/plugins/jz/bm/chat.go, classes/chat_context.go, controllers/api/chat.go, ../sm-demo-app/plugins/golem15/chat/contracts/*.go and classes/writes.go (GetGroupChat, AddUserToGroupChat) after mounting, ../sm-demo-app/plugins/golem15/chatchannels/models/channel.go, classes/channel_service.go (CreateChannel, JoinChannel), contracts/*.go, ../sm-demo-app/scripts/check-pins.sh, the folded todo, .planning/G15OFFICE.md</read_first>
<action>Per D-06, D-07, SC-1 and the folded todo.
1. Folded todo first:
- `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch --tags`, confirm `git merge-base --is-ancestor b6a9586 v0.1.3`, then check out v0.1.3;
- in ../sm-demo-app run `GOWORK=off GOPRIVATE=git.golem15.com/* go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` and `go mod tidy`;
- commit the gitlink with go.mod and go.sum as `chore: bump sm-oauthserver-plugin to v0.1.3`.
In summercms.go, `git mv` the todo to .planning/todos/done/, add a "Done in Phase 20 plan 02" line, and commit it as a planning commit. Move g15office task ssuyq2os to Live with a one-line comment, as .planning/G15OFFICE.md describes.
2. Mount chat and chatchannels at their plan 01 tags:
- `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>`, then check out the tag;
- add both to go.work `use`, and run `GOWORK=off go get <module>@<tag>` plus `go mod tidy`;
- add summer.yaml entries after golem15.user (chat, then chatchannels) and before golem15.demo;
- regenerate plugins.gen.go the way 9451b31 did (the summer build generator);
- append the IDs to boot_test.go wantPlugins and `/_chat/api/v1` and `/_chatchannels/api/v1` to its routed prefixes.
One `feat: mount golem15.<id> <tag>` commit per plugin.
3. plugins/demo/classes/chat_context.go, ported from bm's chat_context.go:
- constants ChatContextType "demo", ChatContextID 1, ChatGroupName "General", ChatBroadcastChannel "chat:context:1", and `ChannelBroadcastChannel(id)` returning "chat:channel:{id}";
- `MemberIDs(ctx, db)` and `IsMember(ctx, db, userID)`: users that are activated, have no deleted_at and are not banned (use the user plugin's own ban column; read its models);
- DemoContext and ChannelContext implement chatcontracts.Context, MessageModerator and LiveMembership. CanModerateMessage is true only for a member of the moderator group in a non-DM conversation. RequireLiveMembership returns true;
- ChatAccess (chancontracts.AccessResolver): CanAccessContext is true for a member when the type is "demo" and the id is 1; CanManageContext is always false (D-07);
- keep bm's `WithMembership` and `MemberFunc` injection so tests can stub membership.
4. plugins/demo/chat.go `registerChat(app)`, called from Boot after wireRealtime:
- with the HasPlugin guard, register alias demo (loader returns DemoContext for id 1 and the unknown-context error otherwise) and alias `chanmodels.ChannelAlias` with `LegacyPHPClass: chanmodels.ChannelMorphName` (loader reads the channel row by id and returns ChannelContext);
- in lagoon.OnDatabase, install ChatAccess on the ChannelService.
Requires() gains golem15.chat and golem15.chatchannels. Config keys go into plugins/demo/config/config.yaml in Task 2.
5. console/seed_chat.go: a step `chat channels and history (en)` that, in this task, creates only #general in context demo/1 (channel row plus backing conversation, exactly the rows CreateChannel writes, through a seed-only resolver or raw SQL) and one welcome message from the first admin or a persona. It is idempotent by slug. Add it to the runSeed step table.
6. plugins/demo/chat_api.go: `GET /_demo/api/v1/chat` behind `jwt.auth` and `throttle:120,1`, mounted from Routes. For a member it answers `{member: true, moderator, context: {type: "demo", id: 1}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [DMs as bm's chatConversationJSON], limits: {max_files, max_bytes, max_embeds}}`. For a non-member it answers `{member: false}`. Errors use `wire.WriteJSON` with `{"error": ...}`.
7. chat_smoke_test.go `TestDemoChatTracer` (Docker): bootDemo, run demo:seed, create and activate a user, then with its JWT:
- GET /_demo/api/v1/chat shows #general with a conversation_id;
- GET /_chat/api/v1/contexts/channel/{channelId}/conversation answers 200;
- POST /_chat/api/v1/conversations/{conversation_id}/messages answers 201;
- GET .../messages contains the text;
- POST /_chatchannels/api/v1/contexts/demo/1/channels is refused (403 or 404).
If the lazy join does not make the user a participant, implement the AddUserToGroupChat fallback in the bootstrap and record it.
Commit the host wiring and the test as `feat: demo chat context, channel adapter and bootstrap`.</action>
<verify>
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer|TestGoModRequiresTags|TestBootAllPlugins)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3 &amp;&amp; ../sm-demo-app/scripts/check-pins.sh</automated>
<fails_when>Non-zero exit: vet or the short suite red; fewer than three "--- PASS" lines (a replace or pseudo-version in go.mod, a plugin not activated, a missing route prefix, the member cannot post, or the channel create was allowed); or check-pins prints a mismatch line instead of "check-pins: ok".</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-demo-app log --oneline -6` shows `chore: bump sm-oauthserver-plugin to v0.1.3`, `feat: mount golem15.chat`, `feat: mount golem15.chatchannels` and the host commit, each separate.
- `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/chat plugins/golem15/chatchannels` shows no leading `+` and each describes as its tag.
- `test -f .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md && ! test -e .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md` exits 0.
- `grep -c 'Alias: *chanmodels.ChannelAlias' ../sm-demo-app/plugins/demo/chat.go` prints 1.
</acceptance_criteria>
<done>A demo member can open the seeded #general channel and post in it through the shared chat API, from a build that requires every module by tag.</done>
</task>
<task type="auto">
<name>Task 2: The rest of the family is mounted and wired: forum, friends, slash commands, video config, herald, and moderation refusing banned content</name>
<files>../sm-demo-app/.gitmodules, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/plugins/golem15/chatbots, ../sm-demo-app/plugins/golem15/chatvideo, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/golem15/herald, ../sm-demo-app/plugins/golem15/userfriends, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/config/golem15.chatvideo.yaml, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/config/config.yaml, ../sm-demo-app/plugins/demo/chat.go, ../sm-demo-app/plugins/demo/chatbots.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/boot_test.go, ../sm-demo-app/chat_smoke_test.go</files>
<read_first>../sm-demo-app/plugins/golem15/forum/README.md, boot.go, contracts/access_resolver.go, ../sm-demo-app/plugins/golem15/chatbots/README.md, contracts/slash_command.go, classes/registry.go, classes/result.go, boot.go, ../sm-demo-app/plugins/golem15/chatvideo/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/herald/README.md and config/config.yaml, ../sm-demo-app/plugins/golem15/userfriends/README.md, ../sm-demo-app/plugins/golem15/moderation/README.md and classes/filter.go, ../sm-demo-app/plugins/golem15/chat/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/golem15/forum/events/events.go and contracts/rejection.go, ../sm-demo-app/plugins/demo/chat.go (Task 1), ../sm-bm-app/plugins/jz/bm/chat.go (config key block)</read_first>
<action>Per D-05, D-08, D-12, D-14, D-17, D-22, D-23, SC-1.
1. Mount chatbots, chatvideo, forum, herald, userfriends and moderation at their plan 01 tags as in Task 1, one `feat: mount golem15.<id> <tag>` commit each. Order in summer.yaml: chatbots and chatvideo after chatchannels; forum, herald and userfriends after them (userfriends after chat, so it sees the chat registry at Boot); moderation; all before golem15.demo. Extend boot_test.go wantPlugins and its prefixes: /_chatbots/api/v1, /_chatvideo/api/v1, /_forum/api/v1, /_herald/api/v1, /_userfriends/api/v1 and /_demo/api/v1/chat.
2. config/golem15.chatvideo.yaml (D-12, D-23): livekit_url "ws://127.0.0.1:7890", livekit_admin_url "http://127.0.0.1:7890", max_duration 900, max_participants 4, token_ttl 900, screen_share false. API key and secret come only from the environment. Herald gets no committed config: its keys come from .env (plan 04).
3. plugins/demo/config/config.yaml: `chat: {moderator_group: demo-moderators, max_embeds: 3}`. chat.go reads them as in bm's config key block and reports the limits in the bootstrap (max_bytes from the chat service's MaxMessageBytes).
4. Forum (D-14, D-22): classes/forum_access.go `ForumAccess`. CanRead and CanPost are true for a member when the context is demo/1; CanManage and CanManageContext are true for members of the moderator group. registerChat looks up `*forum.Plugin` and calls SetAccessResolver, and registers the context alias demo with the forum service when its API requires registration (check ListCategories on an unregistered alias).
5. DMs (D-08): nothing to add beyond DemoContext.HasMember. Verify in the smoke test that two members open a DM with POST /_chat/api/v1/contexts/demo/1/dm.
6. chatbots.go (D-05): `/demo` and `/tour` implement contracts.SlashCommand with IsAvailable true for any non-zero actor and Handle returning classes.Ephemeral with a short text (/tour lists /blog, /presentations, /quizzes, /subscriptions, /faq, /chat, /forum, /friends). Register them in lagoon.OnDatabase after chatbots published its Registry; a missing registry is a boot error naming the summer.yaml order.
7. moderation.go (D-17): in Boot, `app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)` looks up `*modclasses.Filter` per event and returns `chatcontracts.Rejection{Field: "content", Message: v.Message}` when Check finds a violation. `app.Events.Listen[*forumevents.PostSaving]` checks Title (Field "title"), then Body (Field "body"), returning `forumcontracts.Rejection`. A Check error is returned as is (500). A missing filter is a boot error, because golem15.demo requires golem15.moderation.
8. plugin.go Requires() lists every plugin whose tables the seed or reset writes or that the demo wires: chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation, plus the existing ones. The package doc mentions the chat host.
9. chat_smoke_test.go `TestDemoModerationSmoke` (Docker): seed, then set golem15_moderation_settings banned_words to "blockedword" and banned_links to "bad.example". A member's message "this has blockedword" in #general answers 422 errors.content; a forum thread titled "visit bad.example" in a seeded category answers 422 errors.title; a clean message answers 201. Also assert the DM open from step 5 and that GET /_chatbots/api/v1/commands lists demo and tour.
Commit wiring (`feat: demo hosts forum, friends, slash commands and moderation`) separately from the six mount commits.</action>
<verify>
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^(TestDemoModerationSmoke|TestBootAllPlugins|TestGoModRequiresTags|TestDemoChatTracer)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 4 &amp;&amp; ../sm-demo-app/scripts/check-pins.sh &amp;&amp; ../sm-demo-app/scripts/build.sh dev</automated>
<fails_when>Non-zero exit: vet or short suite red; fewer than four "--- PASS" lines (banned content stored, a plugin not activated, a prefix not routed, a go.mod rule broken); check-pins reports a mismatch; or the dev build fails.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE '^ - id: golem15\.(chat|chatchannels|chatbots|chatvideo|forum|herald|userfriends|moderation)$' ../sm-demo-app/summer.yaml` prints 8.
- `grep -c 'max_participants: 4' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1 and `grep -c 'max_duration: 900' ../sm-demo-app/config/golem15.chatvideo.yaml` prints 1.
- `grep -rn 'livekit_api_secret: *"[^"]' ../sm-demo-app/config` prints nothing (no committed secret).
- `git -C ../sm-demo-app status --porcelain -- plugins/golem15` prints nothing (submodules clean, on tags).
</acceptance_criteria>
<done>Every chat-family plugin boots in the demo with its host policy; banned words and links are refused in chat and forum; slash commands answer; video is configured with the D-12 caps.</done>
</task>
<task type="auto">
<name>Task 3: demo:seed fills chat, forum and friends with EN and PL showcase data, and demo:reset wipes and reseeds all of it</name>
<files>../sm-demo-app/plugins/demo/console/seed.go, ../sm-demo-app/plugins/demo/console/seed_chat.go, ../sm-demo-app/plugins/demo/console/seed_forum.go, ../sm-demo-app/plugins/demo/console/seed_friends.go, ../sm-demo-app/plugins/demo/console/reset.go, ../sm-demo-app/chat_smoke_test.go</files>
<read_first>../sm-demo-app/plugins/demo/console/seed.go (step table, created, insertID, exists, visitor), seed_locales.go, reset.go (resetTables order and comments), ../sm-demo-app/reset_test.go and seed_test.go, the baseline migrations of every mounted chat-family plugin (../sm-demo-app/plugins/golem15/{chat,chatchannels,chatbots,chatvideo,forum,herald,userfriends}/updates/*.go) for table names and foreign keys, ../sm-demo-app/plugins/golem15/chatchannels/models (pins, mentions, members, prefs), ../sm-demo-app/plugins/golem15/chat/models (message, reaction, thread summary), modules/breakwater/README.md, 20-RESEARCH.md (Pitfall 10, Anti-Patterns)</read_first>
<action>Per D-09, D-14, D-15 (data), D-22, D-17.
- seed_chat.go:
- `chat personas`: Ada, Ben, Ola and Tomek, as decided above. Each is activated, has a random password hash that is never printed, and is skipped when the email exists.
- `chat channels and history (en)`: #general and #announcements, with English history from the personas, two threads (root plus 2 to 4 replies, keeping chat's thread summary rows consistent the way the service writes them), reactions on a few messages using the keys chat allows (GET /reactions catalog in the plugin), and one pin per channel in chatchannels' pins table.
- `chat channels and history (pl)`: #ogólny (slug ogolny) with Polish history, a thread, reactions and a pin.
- A welcome DM from Ada to the visitor when golem15.demo.visitor.email is set (skipped otherwise).
- Rows are written in created order with timestamps spread over the last few days. Messages are inserted directly: no realtime, no jobs, no events. Steps are idempotent by channel slug and persona email.
- seed_forum.go `forum categories and threads (en, pl)`: categories "General discussion" and "Show and tell" (EN) and "Rozmowy" (PL) in context demo/1, two or three threads each with replies by personas. content is Markdown and content_html comes from `breakwater.Render`. Idempotent by category slug.
- seed_friends.go `friends and group`: accepted friendships among the personas, one pending request from Ola to the visitor when set, and a friend group "Demo crew" owned by Ada with Ben and Tomek as members. Use userfriends' tables and the context singleton id 1. Idempotent.
- seed.go: steps `moderator group demo-moderators` (a user_groups row with the configured code and name "Demo moderators", no members) and `moderation lists` (when banned_words and banned_links are both empty, fill a short, mild default list of common spam words and one link-shortener domain; otherwise "exists"). Order the new steps after the visitor and before the roadmap decks.
- reset.go `resetTables` (RESEARCH Pitfall 10), children first, all before `users`:
- chatvideo participants, then sessions;
- chat reactions, thread mutes, thread summaries, messages, participants, conversations;
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels, categories;
- forum thread reads, posts, threads, categories;
- userfriends group bans, invites, group members, groups, friendships;
- herald push subscriptions;
- chatbots bot flags.
Use the exact table names from the migrations. Keep golem15_moderation_settings and golem15_userfriends_contexts. Update the comment and the command description. Remove the chat-family uploads (chat attachments in system_files) the same way the reset already removes other owners' attachments, keeping resetKeptAttachments.
- chat_smoke_test.go `TestSeedChatSmoke` (Docker): seed with a visitor email.
- Three channels exist, #ogólny holds Polish text, at least one thread root has replies, at least one reaction and one pin exist, the forum has three categories with posts whose content_html is non-empty, there are at least three accepted friendships and the group "Demo crew", and the visitor has a DM.
- A second seed run reports every new step as "exists".
- demo:reset --force empties golem15_chat_messages before reseeding and leaves golem15_moderation_settings unchanged.
Commit `feat: seed and reset the chat family (EN/PL)`.</action>
<verify>
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke|TestResetKeepsPlans|TestSeedRoadmapUpsert)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>Non-zero exit or fewer than three "--- PASS" lines: seed not idempotent, a missing EN/PL item, a foreign-key violation in the reset order, moderation settings wiped, or the existing reset and roadmap tests broken.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'golem15_chat_messages' ../sm-demo-app/plugins/demo/console/reset.go` prints at least 1, and TestSeedChatSmoke asserts the moderation settings row is identical before and after demo:reset --force.
- `grep -c 'personas.demo.invalid' ../sm-demo-app/plugins/demo/console/seed_chat.go` prints at least 1.
- Running `../sm-demo-app/scripts/dev.sh seed` twice against the local stack prints no "created" for the new steps on the second run.
</acceptance_criteria>
<done>A fresh or reset demo shows lived-in EN and PL channels, threads, reactions, pins, forum threads, friendships and a friend group, and the reset job keeps it that way.</done>
</task>
<task type="auto">
<name>Task 4: Every member gets realtime, the layers get their members endpoint, and dev runs Centrifugo namespaces and a local LiveKit</name>
<files>../sm-demo-app/plugins/demo/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go, ../sm-demo-app/plugins/demo/plugin.go, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/scripts/dev.sh, ../sm-demo-app/README.md</files>
<read_first>../sm-bm-app/plugins/jz/bm/realtime_access.go, ../sm-demo-app/plugins/demo/routes.go (mountRealtime), realtime.go, ../sm-demo-app/realtime_test.go (TestRealtimeRoutes, wantNamespaces, realtimeApp), ../sm-bm-app/deploy/centrifugo/bm-config.json (chat, user and video namespace options), ../sm-demo-app/deploy/centrifugo.json.example, ../sm-demo-app/scripts/dev.sh (ensure_env, start_centrifugo, claim_port, up, down, status, the usage header), ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/plugins/golem15/chatvideo/README.md (keys, admin URL, TrustedMode), 20-RESEARCH.md (Pitfalls 4, 5, 7; LiveKit config block)</read_first>
<action>Per D-10 (locally), D-06, SC-1.
1. realtime_access.go: `MiddlewareRealtimeAccess = "demo.realtime-access"`, exported through `Middlewares() map[string]pact.Middleware` (assert pact.HasMiddleware in plugin.go). The guard reads `bouncer.User(r.Context())`: no user gives 401, a database not yet published gives 503, and a user for whom classes.IsMember is true passes. Otherwise it falls back to the presentations owner guard looked up once (sync.Once) by name, as bm does. routes.go mountRealtime mounts when golem15.presentations or golem15.chat is active, with `UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime)`. Update its doc comment.
2. realtime_test.go: the token route's expected middleware becomes ["jwt.auth", "demo.realtime-access", "throttle:golem15.demo.realtime"], and wantNamespaces gains the namespaces the chat, chatvideo and user plugins register. Plan 06 adds the member/non-member cases.
3. chat_api.go `GET /_demo/api/v1/chat/members` (jwt.auth, throttle:120,1) answers `{members: [{id, name, surname, handle, is_bot}]}` for every member except the caller, as bm's ChatMembers does (handle from the user plugin's username or a slug of the name; is_bot false). A non-member gets 403.
4. deploy/centrifugo.json.example: add namespaces `chat` (presence, join_leave, force_push_join_leave, allow_presence_for_subscriber and subscribe_proxy_enabled, with the option names bm's config uses translated to this file's v6 format), `user` (subscribe_proxy_enabled) and `video` (subscribe_proxy_enabled). Run `centrifugo checkconfig -c deploy/centrifugo.json.example`, then diff the option names against `centrifugo defaultconfig`, because checkconfig misses misspelt names. Update the _comment lines.
5. deploy/livekit.yaml.example (RESEARCH LiveKit block):
- port 7890, bind_addresses [127.0.0.1];
- rtc tcp_port 7891, udp_port 7892 (UDP mux only, no port-range keys), use_external_ip true and a comment that local runs set it false;
- room max_participants 4, empty_timeout 120, departure_timeout 20;
- keys with one placeholder pair (CHANGE_ME_KEY: CHANGE_ME_SECRET);
- a header comment naming rome's path /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo), the supervisor program plan 05 adds, and the env keys.
6. scripts/dev.sh:
- LIVEKIT=${LIVEKIT:-$(command -v livekit-server || true)};
- ensure_env appends SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY and SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET (random, umask 077, never echoed) when missing;
- `livekit_config` writes logs/livekit.yaml from the example with the .env keys and use_external_ip false;
- `start_livekit` claims ports 7890 and 7891, starts `livekit-server --config logs/livekit.yaml` under the pid name livekit with output to logs/livekit.log, waits for http://127.0.0.1:7890/ and warns and continues without video when the binary is missing;
- up starts it after Centrifugo, down, destroy and status include livekit, and the header usage lines mention it.
7. deploy/env.example: the two LiveKit key names with empty values and a comment. README.md: a "Chat family" section covering the mounted plugins, the contexts, the bootstrap contract of /_demo/api/v1/chat and /chat/members, the seed data, the moderator group, slash commands and local LiveKit.
Commit `feat: realtime for every demo member, members endpoint, Centrifugo namespaces and local LiveKit`.</action>
<verify>
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/... &amp;&amp; centrifugo checkconfig -c ../sm-demo-app/deploy/centrifugo.json.example &amp;&amp; ../sm-demo-app/scripts/dev.sh up &amp;&amp; curl -fsS http://127.0.0.1:7890/ &gt;/dev/null &amp;&amp; ../sm-demo-app/scripts/check-local.sh realtime</automated>
<fails_when>Non-zero exit: vet or the short suite red (TestRealtimeRoutes middleware or namespaces), checkconfig refuses the Centrifugo file, dev.sh up fails to start a service, LiveKit does not answer on 127.0.0.1:7890, or check-local realtime reports a non-200 token for the visitor or a token without a JWT.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '"name": "chat"' ../sm-demo-app/deploy/centrifugo.json.example` prints 1, and the same for "user" and "video".
- `grep -v '^\s*#' ../sm-demo-app/deploy/livekit.yaml.example | grep -cE 'port_range_(start|end)'` prints 0, and `grep -c 'udp_port: 7892' ../sm-demo-app/deploy/livekit.yaml.example` prints 1.
- With the local stack up, `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $VISITOR_JWT" http://127.0.0.1:8097/api/realtime/token` prints 200 (JWT from the login API).
- `grep -c 'LIVEKIT_API_SECRET=' ../sm-demo-app/deploy/env.example` prints 1 and that line has no value after the equals sign.
</acceptance_criteria>
<done>Every demo member gets a realtime token and the member list, Centrifugo knows the chat namespaces, and a local LiveKit runs with the dev stack.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser to /_demo, /_chat, /_chatchannels, /_forum, /_userfriends, /_chatbots, /_chatvideo | every request untrusted; actor only from the JWT |
| Centrifugo subscribe proxy to Go | channel names come from clients; authorizers decide |
| seed and reset commands to the database | trusted operator commands; persona accounts must stay unusable |
| Go to LiveKit RoomService on loopback | admin calls with the API secret |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-06 | Elevation of privilege | channel creation by members | high | mitigate | ChatAccess.CanManageContext is always false; TestDemoChatTracer asserts POST channels is refused (D-07) |
| T-20-07 | Information disclosure | subscribing to other conversations | high | mitigate | broadcast channels chat:context:1 and chat:channel:{id} are not subscribable (AuthorizeChat admits only chat:conversation:{id}); user:{id} only to itself (user plugin authorizer); video:session:{id} only to session participants |
| T-20-08 | Spoofing | persona accounts | medium | mitigate | random password hashes never printed, emails under .invalid, so no login and no mail; reset wipes and recreates them |
| T-20-09 | Elevation of privilege | moderator rights | medium | mitigate | moderator group membership only through the user admin (D-22); CanModerateMessage limited to non-DM conversations; banned users fail HasMember with live membership on |
| T-20-10 | Tampering | spam and abuse in public chat and forum | high | mitigate | moderation listeners on both seams (TestDemoModerationSmoke), plugin buckets (chat-api, chat-dm, forum-api, video-initiate), Turnstile on register (19.4.1), reset job |
| T-20-11 | Denial of service | realtime token minting for every member | low | mitigate | guard needs a JWT of a live member, per-user bucket golem15.demo.realtime 120/min unchanged |
| T-20-12 | Information disclosure | LiveKit and VAPID secrets | high | mitigate | values only in .env (dev.sh writes them with umask 077, never echoed); env.example holds names; config files hold no secret (acceptance grep) |
| T-20-13 | Denial of service | stale chat-family rows after reset | low | mitigate | reset table list in FK order covering every chat-family table; TestSeedChatSmoke checks messages are emptied |
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package-manager install in this plan; Go modules are the project's tagged plugins from plan 01 (go.sum committed, GOPRIVATE) |
</threat_model>
<verification>
- Task 1: tracer test, go.mod tags, boot test, check-pins.
- Task 2: moderation smoke, boot and go.mod tests, check-pins, dev build.
- Task 3: seed smoke, reset and roadmap tests.
- Task 4: short suite, Centrifugo checkconfig, dev stack up with LiveKit, check-local realtime.
</verification>
<success_criteria>
sm-demo-app builds with every shared plugin including the chat family, and demo:seed fills each with EN/PL showcase data (SC-1). The demo hosts one community where every member can chat, DM, post in the forum and befriend others, with moderation, slash commands and video configured (D-05 to D-09, D-12, D-14, D-17, D-22, D-23).
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md` when done: mount commits with tags, which channel join path holds (lazy join or bootstrap), persona and channel names, the reset table order, the Centrifugo option names used, and any boot warnings left by plugins (for example herald's presence gate).
</output>

View File

@@ -0,0 +1,311 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 02
subsystem: sm-demo-app
tags: [chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends, moderation, livekit, centrifugo, seed]
requires:
- phase: 20-01
provides: the eight chat-family tags (chat v0.1.5 with MessageSaving, forum v0.1.1 with PostSaving, moderation v0.1.0 Filter)
provides:
- "sm-demo-app mounts chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation at their tags"
- "golem15.demo hosts the chat family: contexts demo/1 and channel/{id}, channel and forum access policies, moderation seams, slash commands /demo and /tour"
- "Host bootstrap contract GET /_demo/api/v1/chat and GET /_demo/api/v1/chat/members (for vue-chat-layer, plan 03)"
- "demo.realtime-access: every community member gets a realtime token"
- "demo:seed EN/PL showcase data for chat, forum and friends; demo:reset covers every chat-family table"
- "Local LiveKit in scripts/dev.sh; deploy/livekit.yaml.example and Centrifugo namespaces chat, user, video (for plan 05)"
affects: [20-03, 20-04, 20-05, 20-06]
actuals:
tokens: 36500 # chars/4 over the realized sm-demo-app diff 56c1141..f4ed2c2 without go.sum (146079 chars)
tasks: 4
commits: 1 # MEASURED in summercms.go: git rev-list --count plan_head_before..HEAD (the todo move); sm-demo-app: 12 (git rev-list --count 56c1141..f4ed2c2)
plan_head_before: 159cd5ecf4aac6e48c2579c68c19addb7633cfce # summercms.go
plan_head_after: d6f490a9abcaf4322aec5a072f7842b8f0d3d2c3 # summercms.go before this SUMMARY commit; sm-demo-app 56c1141 -> f4ed2c2
tech-stack:
added:
- "github.com/livekit/server-sdk-go/v2 v2.19.0 and github.com/SherClockHolmes/webpush-go v1.4.0 (transitive, through chatvideo and herald)"
patterns:
- "Channel-as-context adapter: the host registers chatchannels' `channel` alias on chat's registry so a channel's backing conversation resolves"
- "Host wiring in lagoon.OnDatabase after each plugin published its service (ChannelService, ForumService, chatbots Registry, moderation Filter); a missing service is a boot error naming the summer.yaml order"
- "Pre-save veto listeners (MessageSaving, PostSaving) look up the moderation Filter per event"
key-files:
created:
- ../sm-demo-app/plugins/demo/classes/chat_context.go
- ../sm-demo-app/plugins/demo/classes/forum_access.go
- ../sm-demo-app/plugins/demo/chat.go
- ../sm-demo-app/plugins/demo/chat_api.go
- ../sm-demo-app/plugins/demo/chatbots.go
- ../sm-demo-app/plugins/demo/moderation.go
- ../sm-demo-app/plugins/demo/realtime_access.go
- ../sm-demo-app/plugins/demo/console/seed_chat.go
- ../sm-demo-app/plugins/demo/console/seed_forum.go
- ../sm-demo-app/plugins/demo/console/seed_friends.go
- ../sm-demo-app/chat_smoke_test.go
- ../sm-demo-app/config/golem15.chatvideo.yaml
- ../sm-demo-app/config/golem15.chat.yaml
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
- ../sm-demo-app/deploy/livekit.yaml.example
modified:
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.work
- ../sm-demo-app/.gitmodules
- ../sm-demo-app/plugins.gen.go
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/plugins/demo/routes.go
- ../sm-demo-app/plugins/demo/config/config.yaml
- ../sm-demo-app/plugins/demo/console/seed.go
- ../sm-demo-app/plugins/demo/console/reset.go
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/deploy/centrifugo.json.example
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/README.md
- .planning/todos/done/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
key-decisions:
- "Folded oauthserver/journal todo closed without a commit in sm-demo-app: 2133309 and 5ec5941 had already moved oauthserver to v0.1.6 (contains b6a9586) and journal to v0.2.6; not moved back to v0.1.3"
- "Channel join path: GetGroupChat's lazy join holds (opening GET /_chat/api/v1/contexts/channel/{id}/conversation adds the participant row); the bootstrap joins nobody"
- "Bootstrap channels[].joined is 'has not left' (chatchannels prefs.has_left); left channels stay listed with joined false"
- "Chat attachments capped at 5 MiB per message (config/golem15.chat.yaml max_message_bytes 5242880), reported as limits.max_bytes"
- "Forum CanManage and CanManageContext both go to the moderator group, as the plan says; forum needs no RegisterContext for listing or posting"
- "Production livekit_url wss://demo.summercms.io/livekit lives in config/env/production/golem15.chatvideo.yaml"
patterns-established:
- "Seed steps write chat rows by raw SQL in created order and call chat's own jobs.Work to keep thread summaries consistent"
requirements-completed: [SC-1, D-05, D-06, D-07, D-08, D-09, D-10, D-12, D-14, D-17, D-20, D-22, D-23]
coverage:
- id: D1
description: "A registered member reads the bootstrap, opens seeded #general (lazy join), posts and reads back through the chat API; channel creation refused; members endpoint excludes the caller"
requirement: "D-06, D-07"
verification:
- kind: integration
ref: "../sm-demo-app/chat_smoke_test.go#TestDemoChatTracer"
status: pass
human_judgment: false
- id: D2
description: "Banned word in a chat message -> 422 errors.content, banned link in a forum title -> 422 errors.title, clean content stored; DM between two members; /demo and /tour listed and ephemeral"
requirement: "D-17, D-08, D-05"
verification:
- kind: integration
ref: "../sm-demo-app/chat_smoke_test.go#TestDemoModerationSmoke"
status: pass
human_judgment: false
- id: D3
description: "demo:seed EN/PL chat, forum, friends, moderator group, moderation lists; second run all exists; demo:reset --force wipes and reseeds, keeps moderation settings and the userfriends context"
requirement: "D-09, D-22"
verification:
- kind: integration
ref: "../sm-demo-app/chat_smoke_test.go#TestSeedChatSmoke"
status: pass
- kind: other
ref: "scripts/dev.sh seed twice against the local Postgres: every new step 'created', then 'exists'"
status: pass
human_judgment: false
- id: D4
description: "Every plugin activates, prefixes routed, go.mod tags only, pins match"
requirement: "SC-1"
verification:
- kind: integration
ref: "../sm-demo-app/boot_test.go#TestBootAllPlugins, go_mod_test.go#TestGoModRequiresTags, scripts/check-pins.sh (25 modules), scripts/build.sh dev"
status: pass
human_judgment: false
- id: D5
description: "Realtime token for any member (401 without JWT), Centrifugo namespaces, local LiveKit on 7890/7891/7892"
requirement: "D-10, D-06"
verification:
- kind: integration
ref: "../sm-demo-app/realtime_test.go#TestRealtimeRoutes"
status: pass
- kind: other
ref: "dev.sh start_livekit (curl 127.0.0.1:7890 200); new binary on 127.0.0.1:8197: GO_ADDR=127.0.0.1:8197 scripts/check-local.sh realtime ok; fresh registered member: token, /chat, /chat/members 200"
status: pass
human_judgment: false
duration: 33min
completed: 2026-10-30
status: complete
---
# Phase 20 Plan 02: Demo backend for the chat family Summary
**sm-demo-app mounts all eight chat-family plugins at their tags and golem15.demo hosts them as one community. Every registered member can post in the seeded EN/PL channels, DM anyone, use /demo and /tour, post in the forum and get a realtime token. Moderation refuses banned words and links with 422, and a local LiveKit runs from scripts/dev.sh.**
## Performance
- **Duration:** about 33 min
- **Started:** 2026-10-30T17:37:25Z
- **Completed:** 2026-10-30T18:10:23Z
- **Tasks:** 4/4
- **Files modified:** 40 in sm-demo-app (2902 insertions, 111 deletions), 1 in summercms.go
## Accomplishments
- Eight `feat: mount golem15.<id> <tag>` commits. `summer.yaml` lists chat, chatchannels, chatbots, chatvideo, forum, herald, userfriends and moderation after golem15.user and before golem15.demo. Every submodule describes as its tag, and `check-pins` reports ok for 25 modules.
- Host types in `plugins/demo/classes`:
- `DemoContext` (demo/1, `chat:context:1`).
- `ChannelContext` (alias `channel`, `chat:channel:{id}`).
- `ChatAccess`: members may access demo/1 and `CanManageContext` is always false.
- `ForumAccess`.
- `MemberIDs`, `IsMember` and `IsModerator`.
- Membership means activated, not deleted and not banned (`user_throttle.is_banned`).
- The bootstrap `GET /_demo/api/v1/chat` returns `{member, moderator, context, channels[], conversations[], limits}` and `/chat/members` returns `{members[]}`. Both need a JWT and are throttled at 120 a minute.
- Listeners on MessageSaving and PostSaving call the moderation Filter. `/demo` and `/tour` answer ephemerally, in EN or PL from `preferred_locale`.
- demo:seed adds eight steps. demo:reset clears 27 more tables in foreign-key order.
- `demo.realtime-access` guards the token route. Centrifugo gets the namespaces chat, user and video. `deploy/livekit.yaml.example` is added, dev.sh starts and stops a local LiveKit, and the README has a chat family section.
## Task Commits
| # | Task | Repo | Commit | Message |
|---|------|------|--------|---------|
| 1 | Folded todo | summercms.go | d6f490a | docs(20-02): close the sm-demo-app journal/oauthserver pointer todo |
| 1 | Mount chat | sm-demo-app | 7815001 | feat: mount golem15.chat v0.1.5 |
| 1 | Mount chatchannels | sm-demo-app | 6edfae5 | feat: mount golem15.chatchannels v0.1.0 |
| 1 | Tracer host wiring | sm-demo-app | 58ba678 | feat: demo chat context, channel adapter and bootstrap |
| 2 | Mount chatbots | sm-demo-app | 47c7833 | feat: mount golem15.chatbots v0.1.0 |
| 2 | Mount chatvideo | sm-demo-app | 5076d8c | feat: mount golem15.chatvideo v0.1.1 |
| 2 | Mount forum | sm-demo-app | 2dea441 | feat: mount golem15.forum v0.1.1 |
| 2 | Mount herald | sm-demo-app | 64d37a2 | feat: mount golem15.herald v0.1.1 |
| 2 | Mount userfriends | sm-demo-app | a78c543 | feat: mount golem15.userfriends v0.1.1 |
| 2 | Mount moderation | sm-demo-app | 864a14b | feat: mount golem15.moderation v0.1.0 |
| 2 | Wiring | sm-demo-app | b03fe83 | feat: demo hosts forum, friends, slash commands and moderation |
| 3 | Seed and reset | sm-demo-app | 4a94565 | feat: seed and reset the chat family (EN/PL) |
| 4 | Realtime, members, deploy, dev | sm-demo-app | f4ed2c2 | feat: realtime for every demo member, members endpoint, Centrifugo namespaces and local LiveKit |
Nothing was pushed, tagged or deployed. No file inside a plugin submodule was edited.
## Details for later plans
**Channel join path:** the lazy join holds. `GET /_chat/api/v1/contexts/channel/{channelId}/conversation` reaches chat's `GetGroupChat`, which adds the caller as a participant of the existing backing conversation. TestDemoChatTracer asserts the participant row. The bootstrap fallback (`AddUserToGroupChat`) was not needed.
**Personas:** Ada Hart and Ben Carter (en), Ola Nowak and Tomek Wiśniewski (pl). Emails are `<key>@personas.demo.invalid`. Each gets a random bcrypt hash of 32 random bytes, never printed, and a handle from the user plugin's `AssignHandle`.
**Channels:**
| Channel | Slug | Language | Messages | Thread roots | Pins |
|---------|------|----------|----------|--------------|------|
| #general (default) | `general` | en | 13 | 2 | 1 |
| #announcements | `announcements` | en | 5 | 1 | 1 |
| #ogólny | `ogolny` | pl | 15 | 1 | 1 |
All three channels have reactions. Ada sends the visitor a welcome DM.
**Forum:** "General discussion" (`general-discussion`) and "Show and tell" (`show-and-tell`) in English and "Rozmowy" (`rozmowy`) in Polish, with 2, 2 and 3 threads and `content_html` from `breakwater.Render`.
**Friends:** accepted friendships ada-ben, ada-ola, ben-tomek and ola-tomek. Ola has a pending request to the visitor. The group "Demo crew" has Ada as owner and Ben and Tomek as members.
**Moderation defaults** (only while both lists are empty): words `buy followers`, `casino bonus`, `free crypto` and `viagra`; link `bit.ly`.
**Reset table order** (inserted before `users`, children first):
1. chatvideo participants, sessions
2. chatchannels channel_mentions, channel_pins, channel_members, channel_user_prefs, user_context_prefs, personal_categories, channels, channel_categories
3. chat reactions, thread_mutes, thread_summaries, messages, participants, conversations
4. forum thread_reads, posts, threads, categories
5. userfriends group_bans, group_invites, group_members, groups, friendships
6. herald push_subscriptions
7. chatbots bot_flags
Chatchannels comes before chat because channels hang off their backing conversations. The reset keeps `golem15_moderation_settings`, `user_groups` (the moderator group) and `golem15_userfriends_contexts`. Chat attachments were already covered: the reset removes every `system_files` owner except the kept ones.
**Centrifugo option names** (v6.5.1, checked against `centrifugo defaultconfig`):
- `chat`: `presence`, `join_leave`, `force_push_join_leave`, `allow_presence_for_subscriber` and `subscribe_proxy_enabled`.
- `user` and `video`: `subscribe_proxy_enabled` only.
**Boot warnings left by plugins** (`dev.sh seed`):
- The chatchannels and forum deny-all warnings fire from the plugins' own database hooks before golem15.demo's hook installs the resolvers, so they are expected.
- `golem15.chatbots: BotAuthResolver is deny-all`. The demo has no bot persona.
- `golem15.herald: PresenceGate is deny-all` and `golem15.herald: VAPID keys are missing`. Plan 04 handles these.
## Decisions Made
See the key-decisions list in the frontmatter. The plan's own discretion items (aliases, personas, slugs, commands, ports) were applied as written.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Folded todo needed no sm-demo-app commit**
- **Found during:** Task 1 step 1
- **Issue:** go.mod already required sm-oauthserver-plugin v0.1.6 and sm-journal-plugin v0.2.6 (2133309, 5ec5941), and the oauthserver checkout contains b6a9586. Bumping to v0.1.3 would have been a downgrade.
- **Fix:** No `chore: bump sm-oauthserver-plugin to v0.1.3` commit. The todo moved to done with a note (d6f490a).
**2. [Rule 3 - Blocking] realtime_test.go namespaces updated in the mount commits**
- **Found during:** Task 1 and Task 2 (mount chatvideo)
- **Issue:** chat registers the `chat` namespace and chatvideo registers `video`, so the short suite failed.
- **Fix:** `wantNamespaces` was extended in 58ba678 and 5076d8c, so `go test -short` stays green at every commit.
**3. [Rule 3 - Blocking] boot_test.go realtime subtest expected the old guard**
- **Found during:** Task 4
- **Fix:** It now expects `demo.realtime-access`, in f4ed2c2.
**4. [Rule 2 - Missing critical] Chat attachment cap**
- **Issue:** chat's `max_message_bytes` defaults to 0 (no total limit) on a public demo, and the bootstrap reported 0.
- **Fix:** `config/golem15.chat.yaml` sets 5 MiB per message (5 × the 1 MiB upload body limit), in b03fe83.
**5. [Rule 2] Production LiveKit URL**
- **Fix:** Added `config/env/production/golem15.chatvideo.yaml` with `livekit_url: wss://demo.summercms.io/livekit`, so the committed `ws://127.0.0.1:7890` applies locally only (f4ed2c2).
**6. Scope order changes**
- The `chat personas` step landed in Task 1, because #general's welcome message needs a sender. `users` can't send as a backend admin.
- `/chat/members` assertions were added to TestDemoChatTracer in Task 4.
**7. The `pact.HasMiddleware` assertion sits in plugin.go**, as the plan asks. The middleware itself is in realtime_access.go.
**8. Forum RegisterContext not called.** Its only consumer is `forum:rewrite-context-types`. Listing and posting work without it.
### Verification adjustments
- **The Task 4 verify command `centrifugo checkconfig -c deploy/centrifugo.json.example` cannot pass as written.** Centrifugo picks the config type from the file extension ("Unsupported Config Type example"). I ran it on a `.json` copy instead, the way `scripts/check-deploy.sh` does. checkconfig passed, check-deploy passed, and every option name is in `centrifugo defaultconfig`.
- **`scripts/dev.sh up` was not run as a whole.** The dev ports are held by stale processes that dev.sh did not start in this session and cannot stop:
- Centrifugo on 8011 (pid 212066, Oct 22)
- Go on 8097, run as user golem (pid 3405714, Oct 24)
- Nuxt on 3020 from a cursor-agent session (pid 3776486, Oct 27)
Their pid files are stale, so `claim_port` would refuse, and killing other sessions' processes was out of bounds. What I verified instead:
- **LiveKit:** started with dev.sh's own `ensure_env` and `start_livekit` functions. `curl 127.0.0.1:7890/` answered 200, TCP 7890/7891 and UDP 7892 were listening, `logs/livekit.yaml` is mode 600 with no CHANGE_ME left and `use_external_ip: false`.
- **Realtime:** the new binary ran on 127.0.0.1:8197 against the local Postgres, and `GO_ADDR=127.0.0.1:8197 scripts/check-local.sh realtime` printed ok. A freshly registered member got 200 from the token route, `/_demo/api/v1/chat` and `/chat/members`.
- **Seed:** `scripts/dev.sh seed` ran twice. The new steps were `created`, then `exists`.
- Both of my processes were stopped afterwards.
- **Mistake made and fixed:** my first attempt sourced dev.sh's functions from the scratchpad. Its `cd "$(dirname "$0")/.."` then landed in the meta repo `..`, where it wrote `.env`, `logs/` and `storage/app/uploads/public`. All three had been created seconds earlier by that run, and I removed them. Nothing else in the meta repo was touched.
**Total deviations:** 8 plus 2 verification adjustments. **Impact:** none on the plan goal.
## Issues Encountered
- **g15office MCP unavailable** (connection timed out in this session). Task ssuyq2os (the folded todo) was **not** moved to Live and got no comment, and the Phase 20 task xo463rwk got no comment either. The orchestrator or the user needs to do both.
## Known Stubs
None. Every endpoint reads live data. The bootstrap reports `is_bot: false` for everyone on purpose, because the demo has no bot persona (D-05).
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: endpoint | ../sm-demo-app/plugins/demo/chat_api.go | `/chat/members` lists every member's name, surname and handle to any member (planned in T-20 scope; no email or id beyond the user id) |
| threat_flag: config | ../sm-demo-app/config/golem15.chat.yaml | Upload cap 5 MiB per message on the public demo (mitigation, not new surface) |
## User Setup Required
None for this plan. Plan 05 needs the LiveKit key pair on rome (`deploy/env.example`).
## Next Phase Readiness
- Plan 03 (vue-chat-layer) can build on `GET /_demo/api/v1/chat` and `/chat/members` as documented in sm-demo-app README "Chat family". Channels are opened with `GET /_chat/api/v1/contexts/channel/{id}/conversation`.
- Plan 05 deploys `deploy/livekit.yaml.example`, the Centrifugo namespaces (restart summercms-demo-centrifugo), nginx `/livekit/` and the Go allow-list, and the herald VAPID keys.
---
*Phase: 20-demo-instance-with-every-shared-plugin*
*Completed: 2026-10-30*
## Self-Check: PASSED
- All 15 created files listed under key-files exist, and the todo is under `.planning/todos/done/`.
- sm-demo-app commits 7815001, 6edfae5, 58ba678, 47c7833, 5076d8c, 2dea441, 64d37a2, a78c543, 864a14b, b03fe83, 4a94565, f4ed2c2 and summercms.go d6f490a exist.
- `git -C ../sm-demo-app submodule status` shows no `+` or `-`, and the sm-demo-app working tree is clean (`--ignore-submodules=none`).

View File

@@ -0,0 +1,446 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 03
type: execute
wave: 3
depends_on: ["20-02"]
files_modified:
- ../vue-chat-layer/.gitignore
- ../vue-chat-layer/.nvmrc
- ../vue-chat-layer/package.json
- ../vue-chat-layer/pnpm-lock.yaml
- ../vue-chat-layer/pnpm-workspace.yaml
- ../vue-chat-layer/tsconfig.json
- ../vue-chat-layer/nuxt.config.ts
- ../vue-chat-layer/vitest.config.ts
- ../vue-chat-layer/README.md
- ../vue-chat-layer/.playground/nuxt.config.ts
- ../vue-chat-layer/.playground/app/app.vue
- ../vue-chat-layer/app/lib/client.ts
- ../vue-chat-layer/app/lib/types.ts
- ../vue-chat-layer/app/lib/realtime.ts
- ../vue-chat-layer/app/lib/chatEvents.ts
- ../vue-chat-layer/app/lib/chatFormat.ts
- ../vue-chat-layer/app/lib/chatText.ts
- ../vue-chat-layer/app/lib/chatUnread.ts
- ../vue-chat-layer/app/lib/linkEmbeds.ts
- ../vue-chat-layer/app/lib/format.ts
- ../vue-chat-layer/app/lib/media.ts
- ../vue-chat-layer/app/lib/tabs.ts
- ../vue-chat-layer/app/composables/useChatApi.ts
- ../vue-chat-layer/app/composables/useChat.ts
- ../vue-chat-layer/app/composables/useChatNotice.ts
- ../vue-chat-layer/app/components/Chat/Page.vue
- ../vue-chat-layer/app/components/Chat/ChannelList.vue
- ../vue-chat-layer/app/components/Chat/Panel.vue
- ../vue-chat-layer/app/components/Chat/MessageList.vue
- ../vue-chat-layer/app/components/Chat/Message.vue
- ../vue-chat-layer/app/components/Chat/Composer.vue
- ../vue-chat-layer/app/components/Chat/Header.vue
- ../vue-chat-layer/app/components/Chat/DmList.vue
- ../vue-chat-layer/app/components/Chat/MemberPicker.vue
- ../vue-chat-layer/app/components/Chat/MentionPopup.vue
- ../vue-chat-layer/app/components/Chat/ReactionBar.vue
- ../vue-chat-layer/app/components/Chat/LinkEmbed.vue
- ../vue-chat-layer/app/components/Chat/StagingChips.vue
- ../vue-chat-layer/app/components/Chat/DeleteDialog.vue
- ../vue-chat-layer/app/components/Chat/Avatar.vue
- ../vue-chat-layer/app/components/Chat/ThreadPane.vue
- ../vue-chat-layer/app/components/Chat/PinsPanel.vue
- ../vue-chat-layer/app/components/Chat/CommandPicker.vue
- ../vue-chat-layer/app/components/Chat/FloatingPanel.vue
- ../vue-chat-layer/app/components/Chat/Icon.vue
- ../vue-chat-layer/app/components/Chat/Notices.vue
- ../vue-chat-layer/app/assets/css/chat-tokens.css
- ../vue-chat-layer/app/assets/css/chat-base.css
- ../vue-chat-layer/i18n/locales/en.json
- ../vue-chat-layer/i18n/locales/pl.json
- ../vue-chat-layer/tests/smoke.test.ts
- ../sm-demo-app/vue-demo-app/package.json
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
- ../sm-demo-app/vue-demo-app/app/lib/realtime.ts
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/scripts/check-chat.cjs
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/smoke.sh
autonomous: false
requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-07, D-20, D-22, D-23]
estimate:
tokens: 380000
raw_tokens: 380000
tasks: 6
confidence: low
must_haves:
truths:
- "D-01: vue-chat-layer is a new shared Nuxt layer (own repository, tag v0.1.0) ported from the bm SPA's chat components, store, API client and chat libraries, with no Pinia (state is useState composables), no bm entity embeds and no name of a consuming application; host specifics enter only through runtime config (public.chat.hostApiBase), the $chatAuth hook, slots and the link-embed renderer registry."
- "D-02: on /chat a logged-in member sees the seeded channels in a sidebar, opens one, reads history, sends and edits messages, replies in threads (thread pane), sees typing, unread and mention counts, adds and removes reactions, attaches files, mentions members with @, picks a DM partner from the member picker, deletes own messages (moderators also others' in channels, D-22) through the delete dialog, sees channel pins and sees URLs as links with a generic link embed."
- "D-03: every page of vue-demo-app shows a floating chat panel (DM list and unread badge) for logged-in users (not on /chat and not on the full-screen deck pages); the homepage tour has a Chat card; the site is EN and PL."
- "D-04: an anonymous visitor on /chat sees a description and a register-to-chat link to /register?next=/chat; there is no read-only preview, and the floating panel is not rendered."
- "D-05: typing / at the start of the composer opens a command picker filled from GET /_chatbots/api/v1/commands; choosing or sending a command calls POST /_chatbots/api/v1/dispatch and shows the ephemeral answer only to the sender; there is no chatbots layer."
- "D-07: the layer offers no channel creation UI; members join and leave seeded channels."
- "D-23 (extension point): the conversation header exposes a slot that the layer renders only for DM conversations, so a call button placed there (plan 04) appears only in DMs."
- "SC-2: messages from another member arrive without reload through the shared Centrifuge client (one connection per page, shared with the presentations remote through vue-demo-app's app/lib/realtime.ts re-export)."
- "After the Task 5 go-ahead, git.golem15.com/golem15/vue-chat-layer exists with tag v0.1.0 and vue-demo-app pins git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0 in a committed package.json and pnpm-lock.yaml."
artifacts:
- path: "../vue-chat-layer/nuxt.config.ts"
provides: "layer config: runtimeConfig chat.apiBase, public.chat.hostApiBase, css, i18n registration (no routes, no route rules)"
contains: "hostApiBase"
- path: "../vue-chat-layer/app/composables/useChat.ts"
provides: "useState-based chat store (channels, DMs, threads, unread, typing, realtime)"
contains: "useState"
- path: "../vue-chat-layer/app/lib/realtime.ts"
provides: "one shared Centrifuge client with connect, subscribe, presence, openChannel and onUserEvent"
contains: "onUserEvent"
- path: "../vue-chat-layer/app/lib/linkEmbeds.ts"
provides: "linkify and the link-embed renderer registry (registerEmbedRenderer)"
contains: "registerEmbedRenderer"
- path: "../vue-chat-layer/app/components/Chat/CommandPicker.vue"
provides: "slash command picker over the chatbots catalog and dispatch"
contains: "dispatch"
- path: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
provides: "/chat: ChatPage for members, register prompt for anonymous visitors"
contains: "registerToChat"
- path: "../sm-demo-app/scripts/check-chat.cjs"
provides: "Playwright check: the visitor sends a message in #general on /chat"
contains: "check-chat: ok"
key_links:
- from: "../vue-chat-layer/app/composables/useChatApi.ts"
to: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
via: "the layer reads useNuxtApp().$chatAuth {token, refresh, unauthorized}; the app provides it"
pattern: "chatAuth"
- from: "../vue-chat-layer/app/composables/useChat.ts"
to: "../sm-demo-app/plugins/demo/chat_api.go"
via: "bootstrap GET {public.chat.hostApiBase}/chat and /chat/members"
pattern: "hostApiBase"
- from: "../sm-demo-app/vue-demo-app/app/lib/realtime.ts"
to: "../vue-chat-layer/app/lib/realtime.ts"
via: "re-export so presentations and chat share one connection"
pattern: "vue-chat-layer/app/lib/realtime"
- from: "../sm-demo-app/vue-demo-app/app/layouts/default.vue"
to: "../vue-chat-layer/app/components/Chat/FloatingPanel.vue"
via: "ClientOnly ChatFloatingPanel when logged in"
pattern: "ChatFloatingPanel"
prohibitions:
- statement: "vue-chat-layer names no consuming application and has no default host bootstrap path."
status: unresolved
verification: automated
- statement: "No message, name or attachment field is rendered with v-html except the escaped output of the ported chatText renderer."
status: unresolved
verification: judgment
- statement: "No Pinia and no new npm package except those already pinned in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3)."
status: unresolved
verification: automated
- statement: "Nothing is pushed and no repository is created before the Task 5 go-ahead; vue-demo-app is not pushed in this plan (plan 05)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working, chat and channels with realtime included, through the shared layers.
This plan's slice (plan 3 of 6, wave 3, per D-20): `vue-chat-layer` v0.1.0, ported from bm's chat SPA and widened to several channels, threads, pins, a command picker and generic link embeds, then used in vue-demo-app as `/chat`, a floating panel and a tour card, in EN and PL.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Layout of the layer:** pure modules in `app/lib/` (imported explicitly, never auto-imported, so generic names like `initials` cannot clash with an app's utils). Composables `useChat`, `useChatApi` and `useChatNotice` in `app/composables/`. Components in `app/components/Chat/`, auto-registered with the `Chat` prefix (`ChatPage`, `ChatFloatingPanel`). The layer ships components; the app owns routes, page copy and the anonymous prompt (as vue-paymentgateway-layer does).
- **Host contract:** `runtimeConfig.public.chat.hostApiBase` defaults to an empty string, and the layer refuses to bootstrap without it, with a console error naming the key. vue-demo-app sets `/_demo/api/v1`. The README documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the contract any host implements, under neutral names.
- **Auth hook:** `$chatAuth` with `token(): string | null`, `refresh(): Promise<string | null>` and `unauthorized(): void`. One refresh-and-retry on 401 (bm's client renews through `/_user/api/v1/fetch`), then `unauthorized()`.
- **Store:** bm's Pinia setup store becomes a module-level composable over `useState` keys prefixed `chat:` (19.4.1 no-Pinia rule). `general`/`generalId` become `channels[]` and `activeId`.
- **Realtime:** bm's current `lib/realtime.ts` (shared client) is ported with `onUserEvent(handler)`, so other layers (chatvideo, plan 04) get `user:{me}` events they do not own without a second subscription. vue-demo-app's `app/lib/realtime.ts` becomes a re-export, so the presentations remote and chat share one WebSocket.
- **Embeds:** bm's entity embeds and picker are dropped. URLs in text are linkified (escaped, `rel="noopener noreferrer nofollow"`, `target="_blank"`). A message with a URL shows a `ChatLinkEmbed` card with host and path; there is no unfurl backend. `registerEmbedRenderer(match, component)` lets a host render its own entity links (D-01 extension point).
- **Extension slots:** `conversation-actions` in ChatHeader, rendered only for `type === 'dm'` (D-23), and `sidebar-footer` in ChannelList (the friends link, plan 04).
- **Floating panel:** hidden on `/chat`, `/p/**` and `/presentations/**` (full-screen decks) and for anonymous visitors.
- **Development pin:** while the layer has no remote, vue-demo-app depends on `link:../../vue-chat-layer`, committed with the first vue-demo-app commit so every commit builds on this machine. Task 6 replaces it with the tag pin; vue-demo-app is pushed only in plan 05, after the swap, so no pushed commit needs the local path.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- Repository `vue-chat-layer` (tag v0.1.0):
- components `ChatPage`, `ChatChannelList`, `ChatPanel`, `ChatMessageList`, `ChatMessage`, `ChatComposer`, `ChatHeader`, `ChatDmList`, `ChatMemberPicker`, `ChatMentionPopup`, `ChatReactionBar`, `ChatLinkEmbed`, `ChatStagingChips`, `ChatDeleteDialog`, `ChatAvatar`, `ChatThreadPane`, `ChatPinsPanel`, `ChatCommandPicker`, `ChatFloatingPanel`, `ChatIcon`, `ChatNotices`;
- composables `useChat`, `useChatApi`, `useChatNotice`;
- lib exports: `createChatClient`, `REALTIME_WS_PATH`, `connect`, `subscribe`, `presence`, `openChannel`, `onUserEvent`, `disconnect`, `linkify`, `registerEmbedRenderer`, `embedRendererFor`, and the ported chatEvents, chatFormat, chatText and chatUnread functions;
- runtime config `chat.apiBase` (`NUXT_CHAT_API_BASE`), `public.chat.apiBase`, `public.chat.channelsApiBase`, `public.chat.botsApiBase`, `public.chat.hostApiBase`;
- Nuxt plugin hook `$chatAuth`; slots `conversation-actions` and `sidebar-footer`; i18n keys under `chat.*` (EN and PL);
- smoke test `tests/smoke.test.ts`.
- vue-demo-app: page `/chat`, the floating panel in the default layout, Chat tour card (TourCard icon `chat`), i18n keys `chat.*` and `tour.chat.*`, goPaths `/_chat`, `/_chatchannels`, `/_chatbots`, ChatPage inside ClientOnly (the page itself is server-rendered so the banner and the anonymous prompt are in the HTML), `chatAuth` provided in app/plugins/auth.ts, app/lib/realtime.ts re-exporting the layer client.
- sm-demo-app: `scripts/check-chat.cjs`, check-local check `chat`, check-local `tour` expecting the Chat card, smoke.sh covering `/chat`.
<objective>
Build and release vue-chat-layer v0.1.0 and put chat on the demo site: `/chat`, the floating panel, the Chat tour card and the anonymous prompt, in EN and PL.
Purpose: SC-2 (chat and channels with realtime on the public site), D-01 to D-05, D-07 (no channel creation), D-22 (moderator delete) and the D-23 extension point plan 04 uses.
Output: a new repository vue-chat-layer (pushed and tagged after the go-ahead), commits in ../sm-demo-app/vue-demo-app (the submodule; not pushed) and ../sm-demo-app (scripts; not pushed).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
Repos: new ../vue-chat-layer; ../sm-demo-app/vue-demo-app (the submodule, origin golem15/vue-demo-app; never the stale ../vue-demo-app sibling); ../sm-demo-app (scripts). Read-only port source: ../sm-bm-app/frontend/src (components/chat/*, stores/chat.ts and chat.test.ts, api/chat.ts, api/types.ts, lib/chat*.ts, lib/realtime.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue). Layer conventions: ../vue-faq-layer and ../vue-paymentgateway-layer.
Rules: no co-author tags; stage by explicit path; the layer repository gets no remote before Task 5; vue-demo-app is committed in its own repository (never staged from sm-demo-app) and not pushed here; check `git -C ../sm-demo-app/vue-demo-app status --porcelain` before each commit and leave other sessions' edits alone (Phase 33 also edits nuxt.config.ts). The local dev stack runs with `../sm-demo-app/scripts/dev.sh up` (Go 127.0.0.1:8097, Nuxt 127.0.0.1:3020, Centrifugo, LiveKit). Playwright is the global install (`NODE_PATH="$(npm root -g)"`, scripts/lib/browser.cjs).
<interfaces>
Host bootstrap (plan 02, ../sm-demo-app/plugins/demo/chat_api.go): GET /_demo/api/v1/chat gives {member, moderator, context: {type, id}, channels: [{id, slug, name, conversation_id, joined, unread, mentions}], conversations: [{id, type: "dm", other_user_id, unread, last_message: {id, sender_id, content, created_at}}], limits: {max_files, max_bytes, max_embeds}}; GET /_demo/api/v1/chat/members gives {members: [{id, name, surname, handle, is_bot}]}.
chat API /_chat/api/v1: GET|POST /contexts/{type}/{id}/conversation; GET|POST /conversations/{id}/messages; POST /conversations/{id}/read, /typing, /mute; GET|PUT|DELETE /messages/{id}; GET /reactions; POST /messages/{id}/reactions; DELETE /messages/{id}/reactions/{key}; GET|POST /messages/{id}/replies; PUT /threads/{rootId}/mute; POST /attachments (multipart, body limit); GET /attachments/{messageId}/{fileId}; POST /contexts/{type}/{id}/dm. 422 envelope {"error":"Validation failed","errors":{field:[msg]}}, other refusals {"error": msg}, IDOR 404.
chatchannels /_chatchannels/api/v1/contexts/demo/1: GET channels, POST channels/{id}/join and /leave, GET|POST channels/{id}/pins, DELETE channels/{id}/pins/{messageId}, GET channels/{id}/mentions, GET mentions.
chatbots /_chatbots/api/v1: GET /commands gives {commands: [{trigger, description}]}; POST /dispatch {input} gives {mode, payload}; unknown gives 422 errors.input.
Realtime: token GET /api/realtime/token; WebSocket /ws; channels user:{me} (chat:message, typing, read, unread, deleted, reacted, updated events per bm's chatEvents.ts; video:* from chatvideo), chat:conversation:{id}.
vue-demo-app today: nuxt.config.ts (`goPaths`, `extends`, `runtimeConfig`, `routeRules`, `robots.disallow: goPaths`), app/plugins/auth.ts (`paymentGatewayAuth`), app/utils/authCookie.ts (`DEMO_TOKEN_COOKIE`), app/composables/useDemoAuth.ts (`loggedIn`), app/pages/quizzes/index.vue (anonymous prompt with `registerLink` and `quiz.registerToTake`), app/pages/index.vue (`stops`, TourCard), app/components/TourCard.vue (`icon` union, `ICONS` paths), app/lib/realtime.ts (openChannel subset of bm's file), vitest.config.ts (app/**/*.test.ts), package.json (centrifuge 5.7.3, layers pinned by git+https tag).
Layer analogs: ../vue-faq-layer/nuxt.config.ts (currentDir, absolute paths, runtimeConfig), ../vue-paymentgateway-layer/nuxt.config.ts (i18n:registerModule for EN/PL), app/composables/usePaymentGatewayApi.ts (auth hook through useNuxtApp), package.json (main nuxt.config.ts, peerDependencies nuxt 4.4.8, engines node >=22.18).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a logged-in visitor opens /chat, picks #general from the seeded channels and sends a message that appears; an anonymous visitor gets the register prompt</name>
<reversibility rating="costly">D-01: a public layer with tagged releases that later sites pin; its runtime-config keys, auth hook and bootstrap contract become a contract.</reversibility>
<precondition>The plan 02 SUMMARY exists; `../sm-demo-app/scripts/dev.sh up` brings the stack up and `curl -fsS http://127.0.0.1:8097/_demo/api/v1/roadmap` answers; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
<files>../vue-chat-layer/.gitignore, ../vue-chat-layer/.nvmrc, ../vue-chat-layer/package.json, ../vue-chat-layer/pnpm-lock.yaml, ../vue-chat-layer/pnpm-workspace.yaml, ../vue-chat-layer/tsconfig.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/.playground/nuxt.config.ts, ../vue-chat-layer/.playground/app/app.vue, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/lib/types.ts, ../vue-chat-layer/app/composables/useChatApi.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/components/Chat/Page.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/tests/smoke.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh</files>
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,pnpm-workspace.yaml,tsconfig.json,.gitignore,.nvmrc,.playground/nuxt.config.ts}, ../vue-paymentgateway-layer/nuxt.config.ts and app/composables/usePaymentGatewayApi.ts and app/utils/client.ts, ../sm-bm-app/frontend/src/api/chat.ts and api/types.ts, ../sm-bm-app/frontend/src/stores/chat.ts (bootstrap, open, send), ../sm-bm-app/frontend/src/components/chat/{ChatPanel,MessageList,ChatComposer}.vue, ../sm-demo-app/plugins/demo/chat_api.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts,app/pages/quizzes/index.vue,app/composables/useDemoAuth.ts,app/utils/authCookie.ts,i18n/locales/en.json}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, check_remote, ALL_CHECKS), ../sm-demo-app/scripts/check-remote.cjs and scripts/lib/browser.cjs, 20-PATTERNS.md (vue-chat-layer)</read_first>
<action>Per D-01, D-04, D-07, SC-2. One path through every layer: app page, layer component, store, client, host bootstrap, chat API, database, back to the list.
1. Create the repository: `git init -b main` at /media/nvme/dev/golem15/summercms.io/summercms/vue-chat-layer with the vue-faq-layer files (.gitignore, .nvmrc, tsconfig.json, pnpm-workspace.yaml allowing the same builds plus `protobufjs: false`).
- package.json: name vue-chat-layer, version 0.1.0, private, type module, main ./nuxt.config.ts, engines node >=22.18, peerDependencies nuxt 4.4.8;
- dependencies centrifuge 5.7.3 and @nuxtjs/i18n 10.4.0; devDependencies nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vitest 3.2.7;
- scripts test (`vitest run`), dev (`nuxi dev .playground`), build:playground and dev:prepare.
Run pnpm install.
2. nuxt.config.ts:
- the header comment says an app consumes it with `extends: ['vue-chat-layer']`, provides `$chatAuth` and sets `public.chat.hostApiBase`; currentDir with absolute paths;
- runtimeConfig.chat.apiBase for SSR (`http://127.0.0.1:8096/_chat/api/v1`, NUXT_CHAT_API_BASE);
- public.chat: apiBase `/_chat/api/v1`, channelsApiBase `/_chatchannels/api/v1`, botsApiBase `/_chatbots/api/v1`, hostApiBase empty;
- no pages and no route rules: the app owns its routes and wraps the chat components in ClientOnly (the components touch window and WebSocket, RESEARCH Pitfall 9);
- the i18n:registerModule block of vue-paymentgateway-layer for en and pl (strings come in Task 3; create empty-object locale files now if the module needs them).
3. app/lib/client.ts: `createChatClient({ bases, auth, fetch })`, pure and Nuxt-free so tests import it. It builds URLs, sends Bearer from `auth.token()`, retries once after `auth.refresh()` on 401 and then calls `auth.unauthorized()`, and turns the 422 envelope into a typed `ChatApiError` with field errors. Methods for the tracer: `bootstrap()`, `members()`, `openConversation(type, id)`, `messages(conversationId, opts)` and `send(conversationId, body)`. app/lib/types.ts holds the bootstrap and message types ported from bm's api/types.ts. app/composables/useChatApi.ts binds the client to runtime config and `useNuxtApp().$chatAuth`, following usePaymentGatewayApi.
4. app/composables/useChat.ts, minimal for now, over `useState('chat:…')`: `start()` loads the bootstrap (and logs a console error, then stops, when hostApiBase is empty); `channels`; `activeId`; `open(channel)` calls openConversation('channel', channel.id) then messages; `send(text)`.
5. Components ChatPage (sidebar plus pane), ChatChannelList (channel names, no create button), ChatMessageList (plain text in this task) and ChatComposer (textarea plus send). No v-html.
6. vue-demo-app:
- run `pnpm --dir ../sm-demo-app/vue-demo-app add vue-chat-layer@link:../../vue-chat-layer` (development pin, committed with this task's vue-demo-app commit; Task 6 swaps it for the tag before any push);
- nuxt.config.ts: append 'vue-chat-layer' to extends; add '/_chat', '/_chatchannels' and '/_chatbots' to goPaths; set `public.chat.hostApiBase: '/_demo/api/v1'` and `chat.apiBase` from `api`; no route rule for /chat (the page is server-rendered with the chat inside ClientOnly, so smoke.sh still sees the banner and the prompt);
- app/plugins/auth.ts provides `chatAuth: { token, refresh, unauthorized }`. token reads DEMO_TOKEN_COOKIE. refresh calls the user plugin's refresh endpoint that the login flow uses (read useDemoAuth) and stores the new token. unauthorized clears the cookie;
- app/pages/chat.vue: for loggedIn, `<ClientOnly><ChatPage /></ClientOnly>`; otherwise a description and a `NuxtLink class="button"` to `{ path: '/register', query: { next: '/chat' } }` labelled `t('chat.registerToChat')`, as the quizzes page does. useSeoMeta with title and description;
- i18n en and pl: chat.title, chat.lead and chat.registerToChat.
7. sm-demo-app/scripts/check-chat.cjs (CommonJS, Playwright, modeled on check-remote.cjs):
- `--base` (Nuxt origin) and DEMO_JWT from the environment;
- sets the DEMO_TOKEN_COOKIE cookie with the JWT, opens {base}/chat, clicks the channel whose name is general and sends a unique text;
- waits up to 10 s for the text in the message list, then fails on a console error or a 4xx/5xx response from /_chat;
- opens {base}/chat in a fresh context without the cookie and asserts the register link to /register?next=/chat;
- prints `check-chat: ok`.
check-local.sh gets `check_chat`, which takes visitor_jwt and runs the script as check_remote does, and `chat` in ALL_CHECKS plus the header list.
8. tests/smoke.test.ts (vitest): createChatClient builds `/_chat/api/v1/conversations/7/messages`, sends the Bearer header, retries once on 401 after refresh and maps a 422 body to field errors.
Commit vue-chat-layer (`feat: chat layer tracer: bootstrap, channel list, messages, composer`). Commit vue-demo-app package.json, pnpm-lock.yaml (link pin), nuxt.config.ts, auth.ts, chat.vue and locales by path (`feat: /chat page on vue-chat-layer`). Commit sm-demo-app scripts (`test: check-chat browser check`).</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/dev.sh up &amp;&amp; ../sm-demo-app/scripts/check-local.sh chat</automated>
<fails_when>Non-zero exit: the layer smoke test fails; the Nuxt build fails (layer not resolved, type or template error); the stack does not come up; or check-local prints "check-local: chat failed" (the sent text does not appear, /_chat answered 4xx/5xx, or the anonymous page lacks the register link).</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../vue-chat-layer remote` prints nothing.
- `grep -c "hostApiBase: ''" ../vue-chat-layer/nuxt.config.ts` prints 1.
- `grep -rniE 'sm-bm|jz\.bm|_demo/api' ../vue-chat-layer/app ../vue-chat-layer/nuxt.config.ts ../vue-chat-layer/README.md 2>/dev/null | wc -l` prints 0.
- `grep -c "'/_chat'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `link:../../vue-chat-layer` (the development pin, swapped in Task 6).
</acceptance_criteria>
<done>A visitor can chat in #general on the local demo site through the new layer, and an anonymous visitor is invited to register.</done>
</task>
<task type="auto">
<name>Task 2: The bm chat experience on the layer: realtime, DMs, member picker, mentions, unread counts and typing</name>
<files>../vue-chat-layer/app/lib/realtime.ts, ../vue-chat-layer/app/lib/chatEvents.ts, ../vue-chat-layer/app/lib/chatFormat.ts, ../vue-chat-layer/app/lib/chatText.ts, ../vue-chat-layer/app/lib/chatUnread.ts, ../vue-chat-layer/app/lib/format.ts, ../vue-chat-layer/app/lib/media.ts, ../vue-chat-layer/app/lib/tabs.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/composables/useChatNotice.ts, ../vue-chat-layer/app/components/Chat/Panel.vue, ../vue-chat-layer/app/components/Chat/MessageList.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/DmList.vue, ../vue-chat-layer/app/components/Chat/MemberPicker.vue, ../vue-chat-layer/app/components/Chat/MentionPopup.vue, ../vue-chat-layer/app/components/Chat/Avatar.vue, ../vue-chat-layer/app/components/Chat/Icon.vue, ../vue-chat-layer/app/components/Chat/Notices.vue, ../vue-chat-layer/app/components/Chat/Page.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts</files>
<read_first>../sm-bm-app/frontend/src/stores/chat.ts (whole store), stores/chat.test.ts, lib/realtime.ts, lib/chatEvents.ts, lib/chatFormat.ts, lib/chatText.ts, lib/chatUnread.ts, lib/format.ts, lib/media.ts, lib/tabs.ts, components/Icon.vue, components/chat/{ChatPanel,MessageList,ChatMessage,ChatComposer,ChatHeader,DmList,MemberPicker,MentionPopup,Avatar}.vue, ../sm-demo-app/vue-demo-app/app/lib/realtime.ts and realtime.test.ts, app/pages/presentations/[slug]/index.vue and pilot.vue (openChannel callers), ../vue-chat-layer/app/composables/useChat.ts (Task 1), 20-RESEARCH.md (Pattern 3, Pitfall 9)</read_first>
<action>Per D-01, D-02, SC-2.
- Port bm's lib/realtime.ts into app/lib/realtime.ts. It keeps the same exports (REALTIME_WS_PATH, tokenFailureIsFinal, wsURL, connect, subscribe, presence, onConnection, onUnavailable, realtimeState, realtimeUnavailable, disconnect, openChannel). The token is fetched from `/api/realtime/token` with the `$chatAuth` token through a setter `setRealtimeAuth(getToken)`, so the module stays Nuxt-free. `centrifuge` is loaded with a dynamic import. Add `onUserEvent(handler): () => void`: the chat store's `user:{me}` subscription hands every event it does not handle to the registered handlers, so a second layer never subscribes to `user:{me}` itself.
- Port chatEvents, chatFormat, chatText and chatUnread unchanged except for import paths, and the helpers they need from format, media and tabs. The bm tests come in plan 06. chatText's renderer escapes before it marks mentions; keep it the only raw-HTML binding in the layer.
- useChat: port bm's store as a composable over useState. Replace `general` and `generalId` with `channels`, `activeId` and a `kind` ('channel' or 'dm') for the open conversation. Keep:
- DMs (`conversations`; `openDm(userId)` through POST /contexts/demo/1/dm, using the context from the bootstrap);
- the member list from `/chat/members`;
- unread and mention counts, read marks and typing, with bm's polling fallback when realtime is unavailable (15 s open conversation, 60 s counts);
- realtime start for a member session, edits and deletions from events.
Channel join and leave call chatchannels join and leave and refresh the bootstrap. Errors go to useChatNotice (a small toast state rendered by ChatNotices inside ChatPage and the floating panel), replacing bm's toast store.
- Port the components ChatPanel, ChatMessageList, ChatMessage (text through chatText, attachment rendering in Task 3), ChatComposer (mention popup, Enter to send, Shift+Enter newline), ChatHeader (unread and mention badges), ChatDmList, ChatMemberPicker, ChatMentionPopup and ChatAvatar, plus ChatIcon with the icons they use. ChatPage composes sidebar (channels, DMs, a new-DM button opening the member picker) and pane. Strip every bm-specific element (entity embeds, deadline pins, the student app's tabs) or leave a named slot in its place.
- vue-demo-app app/lib/realtime.ts becomes a re-export of `vue-chat-layer/app/lib/realtime` (with a comment), and the app plugin calls `setRealtimeAuth` with the DEMO_TOKEN_COOKIE reader. If the existing app/lib/realtime.test.ts fails against the shared client, replace it with bm's current realtime.test.ts, adjusted only in import paths.
Commit vue-chat-layer (`feat: port the chat store, realtime client and components`) and vue-demo-app (`refactor: share the chat layer's realtime client`).</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh chat realtime remote</automated>
<fails_when>Non-zero exit: layer smoke or vue-demo-app vitest red (the realtime re-export broke the presentations tests), the build fails, or check-local reports chat, realtime or remote failed (the presentations remote no longer works through the shared client).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'export function onUserEvent' ../vue-chat-layer/app/lib/realtime.ts` prints 1.
- `grep -rn "from 'pinia'" ../vue-chat-layer/app | wc -l` prints 0.
- `grep -c 'vue-chat-layer/app/lib/realtime' ../sm-demo-app/vue-demo-app/app/lib/realtime.ts` prints 1.
- `grep -rn 'v-html' ../vue-chat-layer/app/components | grep -v 'renderChatText\|rendered' | wc -l` prints 0.
</acceptance_criteria>
<done>The layer behaves like bm's chat across several channels and DMs, live, with one shared realtime connection on every page.</done>
</task>
<task type="auto">
<name>Task 3: Threads, pins, reactions, attachments, moderator delete, link embeds and the slash command picker</name>
<files>../vue-chat-layer/app/components/Chat/ThreadPane.vue, ../vue-chat-layer/app/components/Chat/PinsPanel.vue, ../vue-chat-layer/app/components/Chat/ReactionBar.vue, ../vue-chat-layer/app/components/Chat/StagingChips.vue, ../vue-chat-layer/app/components/Chat/DeleteDialog.vue, ../vue-chat-layer/app/components/Chat/LinkEmbed.vue, ../vue-chat-layer/app/components/Chat/CommandPicker.vue, ../vue-chat-layer/app/components/Chat/Message.vue, ../vue-chat-layer/app/components/Chat/Composer.vue, ../vue-chat-layer/app/components/Chat/Header.vue, ../vue-chat-layer/app/components/Chat/ChannelList.vue, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/client.ts, ../vue-chat-layer/app/composables/useChat.ts, ../vue-chat-layer/app/assets/css/chat-tokens.css, ../vue-chat-layer/app/assets/css/chat-base.css, ../vue-chat-layer/i18n/locales/en.json, ../vue-chat-layer/i18n/locales/pl.json, ../vue-chat-layer/nuxt.config.ts, ../vue-chat-layer/README.md</files>
<read_first>../sm-bm-app/frontend/src/components/chat/{ReactionBar,StagingChips,DeleteDialog,ChatMessage,ChatComposer,EmbedCard}.vue, ../sm-bm-app/frontend/src/lib/embeds.ts (what to drop), ../sm-demo-app/plugins/golem15/chat/README.md (replies, thread mute, reactions catalog, attachments and limits), ../sm-demo-app/plugins/golem15/chatchannels/README.md (pins, mentions), ../sm-demo-app/plugins/golem15/chatbots/README.md and api/commands.go, ../vue-paymentgateway-layer/{README.md,app/assets/css/pg-tokens.css}, ../vue-faq-layer/README.md (layer README structure), 20-RESEARCH.md (Open Question 4, Anti-Patterns)</read_first>
<action>Per D-02, D-05, D-22, D-23, D-01.
- Threads: a reply count on root messages opens ChatThreadPane, which lists replies (GET /messages/{id}/replies), posts replies (POST /messages/{id}/replies) and mutes the thread (PUT /threads/{rootId}/mute). Realtime reply events update the count.
- Pins: ChatPinsPanel, opened from the header of a channel, lists GET .../channels/{id}/pins. Pin and unpin show only when the API allows it: a refused pin shows a notice and hides the action for the session.
- Reactions: ChatReactionBar ported, with the keys from GET /reactions.
- Attachments: ChatStagingChips ported. Uploads go to POST /attachments within the bootstrap limits (max_files, max_bytes), and the message shows a download link (GET /attachments/{messageId}/{fileId} with the auth header, fetched as a blob).
- Delete: ChatDeleteDialog ported. Own messages always; others' only when the bootstrap's moderator is true and the conversation is a channel (D-22).
- Edits: an Edit action on own messages through PUT /messages/{id}.
- Link embeds: app/lib/linkEmbeds.ts with `linkify(escapedHtml)`, `registerEmbedRenderer(match: (url: URL) => boolean, component)` and `embedRendererFor(url)`. ChatMessage renders the first URL of a message with a registered renderer or with ChatLinkEmbed (host and path, no fetch). max_embeds caps the cards.
- Commands (D-05): typing "/" at the start of the composer opens ChatCommandPicker, filled from GET /commands (cached per session) and filtered by prefix. Sending a "/" message calls POST /dispatch with {input} instead of posting. An ephemeral answer renders as a local, dismissable, sender-only system row with the payload text (escaped); a 422 shows the error.
- Slots: ChatHeader renders `<slot name="conversation-actions" :conversation>` only when the open conversation is a DM (D-23). ChatChannelList renders `<slot name="sidebar-footer" />`. ChatPage forwards both slots.
- Styling: chat-tokens.css and chat-base.css (neutral tokens an app overrides, as pg-tokens.css does), registered in nuxt.config.ts css with absolute paths.
- i18n: every UI string of the layer in en and pl under `chat.*`. The app's own copy stays in the app.
- README.md in the layer structure of vue-faq-layer and vue-paymentgateway-layer:
- install by tag; extends;
- the `$chatAuth` hook; the runtime-config keys;
- the host bootstrap contract (the two JSON shapes with a neutral `acme` host);
- slots, `registerEmbedRenderer`, `onUserEvent`;
- client-only rendering (wrap ChatPage and ChatFloatingPanel in ClientOnly); testing.
Name no consuming application.
Commit vue-chat-layer (`feat: threads, pins, reactions, attachments, link embeds and the command picker`).</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; pnpm --dir ../vue-chat-layer run build:playground &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh chat</automated>
<fails_when>Non-zero exit: the layer smoke test, the playground build or the app build fails, or check-local prints "check-local: chat failed".</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'conversation-actions' ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1, and the slot sits inside a condition on the DM type (`grep -c "type === 'dm'" ../vue-chat-layer/app/components/Chat/Header.vue` prints at least 1).
- `grep -c 'export function registerEmbedRenderer' ../vue-chat-layer/app/lib/linkEmbeds.ts` prints 1.
- `grep -c '/dispatch' ../vue-chat-layer/app/lib/client.ts` prints at least 1.
- `node -e "const a=require('../vue-chat-layer/i18n/locales/en.json'),b=require('../vue-chat-layer/i18n/locales/pl.json');const k=o=>Object.entries(o).flatMap(([x,v])=>typeof v==='object'?k(v).map(y=>x+'.'+y):[x]);const d=k(a).filter(x=>!k(b).includes(x));if(d.length){console.error(d);process.exit(1)}"` exits 0 (PL covers every EN key).
- In a manual run on the local stack, a "/demo" command in the composer shows the ephemeral answer and nothing is posted to the channel (recorded in the SUMMARY).
</acceptance_criteria>
<done>The layer has the full D-02 feature set plus the slash command picker and the extension points other layers and hosts plug into.</done>
</task>
<task type="auto">
<name>Task 4: Chat everywhere on the demo site: floating panel, Chat tour card, EN/PL copy and updated checks</name>
<files>../vue-chat-layer/app/components/Chat/FloatingPanel.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
<read_first>../sm-demo-app/vue-demo-app/app/layouts/default.vue, app/pages/index.vue (stops, TourCard), app/components/TourCard.vue (icon union, ICONS), app/composables/useDemoAuth.ts, i18n/locales/en.json and pl.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour, card_titles), ../sm-demo-app/scripts/smoke.sh (page loop at line 237), ../sm-bm-app/frontend/src/components/chat/DmList.vue</read_first>
<action>Per D-03, D-04.
- Layer ChatFloatingPanel: a corner button with the total unread badge (DMs plus channel mentions). It opens a compact panel with ChatDmList and the open DM's message list and composer, and has a link to the full chat page whose path comes from a `chatPath` prop (default '/chat'). It starts the chat store only for a member and renders nothing otherwise.
- vue-demo-app default.vue: inside `<ClientOnly>`, render `<ChatFloatingPanel>` when loggedIn and the route is not /chat, /p/** or /presentations/**.
- index.vue: a Chat stop `{ key: 'chat', to: '/chat' }` after quizzes. TourCard gets an outline chat icon in ICONS and 'chat' in the icon union. en and pl get tour.chat.title and tour.chat.text, and the tour heading text matches the new count ("Seven things to try" / the Polish equivalent).
- chat.vue: final copy (EN/PL) explaining channels, DMs, threads and slash commands, plus the register prompt.
- check-local.sh check_tour expects Chat (EN) and Czat (PL) among the cards and seven cards. smoke.sh adds /chat to the page loop (banner present; the anonymous prompt link to /register?next=%2Fchat in the HTML).
Commit the layer (`feat: floating chat panel`), vue-demo-app (`feat: floating chat panel, Chat tour card`) and sm-demo-app scripts separately.</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh tour chat</automated>
<fails_when>Non-zero exit: build or tests fail, or check-local reports tour failed (missing Chat or Czat card, wrong card count) or chat failed.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'ChatFloatingPanel' ../sm-demo-app/vue-demo-app/app/layouts/default.vue` prints 1 and the element sits inside ClientOnly.
- `grep -c "key: 'chat'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 1.
- `grep -c '/chat' ../sm-demo-app/scripts/smoke.sh` prints at least 1.
</acceptance_criteria>
<done>Chat is reachable from every page for members and from the tour for everyone, in EN and PL.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 5: Go-ahead to create vue-chat-layer on git.golem15.com, push it and tag v0.1.0</name>
<read_first>Claude's summary before asking: `git -C ../vue-chat-layer log --oneline`, the README's install line, the visibility of vue-faq-layer on git.golem15.com (consumers install layers over git+https), whether `tea login list` shows a git.golem15.com login, and the result of the Task 4 checks.</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY; with "release", `git ls-remote https://git.golem15.com/golem15/vue-chat-layer.git` exits 0 before Task 6 pushes, or Task 6 creates it with tea.
</acceptance_criteria>
<decision>Create the vue-chat-layer repository, push main and tag v0.1.0 now?</decision>
<context>D-01 rates the layer costly: later sites pin it by tag, so v0.1.0 freezes the hook, runtime-config and bootstrap contract shown in the README. A pushed tag must not move. vue-demo-app can only pin the layer by tag after this; until then it builds from a local link that cannot be pushed. Repository creation and pushes need your go-ahead (CLAUDE.md).</context>
<options>
<option id="release">
<name>Create, push and tag v0.1.0</name>
<pros>vue-demo-app pins the tag and plan 04 can build vue-chatvideo-layer against it.</pros>
<cons>The v0.1.0 contract is public; changes need v0.1.1 or v0.2.0.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>The layer's contract can still change freely.</pros>
<cons>Plan 04 (chatvideo extends the chat contract) and the deploy are blocked; vue-demo-app keeps a local link pin, so it cannot be pushed.</cons>
</option>
</options>
<instructions>For "release" without a tea login: create golem15/vue-chat-layer (empty, default branch main, same visibility as vue-faq-layer) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
<resume-signal>Reply "release" (add "repo ready" if you created it) or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 6: Release vue-chat-layer v0.1.0 and pin it in vue-demo-app</name>
<reversibility rating="one-way">A pushed layer tag is installed by consumers' lockfiles; it can be superseded but must never move.</reversibility>
<precondition>Task 5 resolved with "release".</precondition>
<files>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
<read_first>../vue-chat-layer/README.md, ../sm-demo-app/vue-demo-app/package.json (other layer pins), the Task 5 answer</read_first>
<action>Per D-01.
1. If the repository is missing, create it with `tea repo create --name vue-chat-layer --owner golem15` at vue-faq-layer's visibility.
2. Add the remote `git@git.golem15.com:golem15/vue-chat-layer.git` and push main.
3. If the README's install line says no tag exists yet, fix it before tagging. Create the annotated tag v0.1.0, push it, and confirm `git ls-remote origin refs/tags/v0.1.0^{}` equals HEAD.
4. In vue-demo-app run `pnpm add "vue-chat-layer@git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0"` (replacing the link pin), then build and test.
5. Commit package.json and pnpm-lock.yaml (`feat: pin vue-chat-layer v0.1.0`). Do not push vue-demo-app (plan 05).</action>
<verify>
<automated>[ "$(git -C ../vue-chat-layer ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../vue-chat-layer rev-parse HEAD)" ] &amp;&amp; grep -q 'vue-chat-layer.git#v0.1.0' ../sm-demo-app/vue-demo-app/package.json &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
<fails_when>Non-zero exit: the remote tag is missing or differs from HEAD, package.json still holds the link pin, or the vue-demo-app tests or build fail against the tagged layer.</fails_when>
</verify>
<acceptance_criteria>
- `node -p "require('../sm-demo-app/vue-demo-app/package.json').dependencies['vue-chat-layer']"` prints `git+https://git.golem15.com/golem15/vue-chat-layer.git#v0.1.0`.
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
- `../sm-demo-app/scripts/check-local.sh chat tour` prints `check-local: ok chat tour`.
</acceptance_criteria>
<done>vue-chat-layer v0.1.0 is published and the demo site builds against the tag.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| other members' messages, names and URLs to the browser | untrusted content rendered in every member's page |
| browser to the chat-family APIs | JWT bearer from the demo cookie; actor fixed server-side |
| browser to Centrifugo | connection token per member; subscriptions authorized server-side |
| pnpm install from git.golem15.com | the layer tag becomes an input of every consuming site |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-14 | Tampering | stored XSS through message text, names or URLs | high | mitigate | text rendered through the ported chatText renderer that escapes before marking mentions; linkify runs on escaped HTML and only emits http(s) anchors with rel noopener noreferrer nofollow; names and payloads are text bindings; acceptance grep limits v-html to the renderer output |
| T-20-15 | Information disclosure | JWT handling in the layer | medium | mitigate | the token comes only from the app's $chatAuth hook (cookie reader), is sent as a Bearer header to same-origin paths, never stored by the layer, and cleared through unauthorized() after a failed refresh |
| T-20-16 | Information disclosure | attachment downloads | medium | mitigate | downloads go through GET /attachments/{messageId}/{fileId} with the Bearer header (participants only server-side); no public URL is built |
| T-20-17 | Spoofing | slash command answers | low | mitigate | ephemeral answers are rendered locally as escaped text marked as a system row and never posted as the user |
| T-20-18 | Tampering | layer supply chain | medium | mitigate | the layer pins exact versions already audited in the workspace (centrifuge 5.7.3, @nuxtjs/i18n 10.4.0); vue-demo-app pins the layer by tag with a committed lockfile |
| T-20-19 | Denial of service | realtime connections | low | mitigate | one shared Centrifuge client per page (re-export in vue-demo-app); polling fallback intervals of 15 s and 60 s from bm |
| T-20-SC | Tampering | npm installs | high | mitigate | no new package: centrifuge 5.7.3 was audited in 19.4.1 (protobufjs postinstall disabled in pnpm-workspace.yaml); @nuxtjs/i18n 10.4.0, vitest 3.2.7, nuxt, vue and typescript are the pins already in the workspace lockfiles |
</threat_model>
<verification>
- Task 1: layer smoke, app build, check-local chat (browser send, anonymous prompt).
- Task 2: layer and app vitest, build, check-local chat, realtime and remote.
- Task 3: layer smoke, playground build, app build, check-local chat, PL key coverage.
- Task 4: build, check-local tour and chat, smoke.sh page list.
- Task 5: go-ahead recorded.
- Task 6: remote tag equals HEAD, vue-demo-app builds against the tag.
</verification>
<success_criteria>
The public demo site has `/chat` with channels, DMs, threads, pins, reactions, attachments, mentions, typing, unread counts, link embeds and slash commands working live (SC-2, D-01 to D-05), a floating panel for members and an invitation for visitors. vue-chat-layer v0.1.0 is a reusable, app-neutral layer.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md` when done: the layer's public API (runtime keys, hook, slots, exports), what was stripped from bm and why, the refresh endpoint used by chatAuth, which realtime test file is used in vue-demo-app, the tag and commit, and the checkpoint answer.
</output>

View File

@@ -0,0 +1,298 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 03
subsystem: vue-chat-layer, vue-demo-app
tags: [chat, nuxt-layer, realtime, centrifuge, i18n, slash-commands, threads, pins]
requires:
- phase: 20-02
provides: "Host bootstrap GET /_demo/api/v1/chat and /chat/members, the chat family mounted, realtime for every member"
provides:
- "vue-chat-layer v0.1.0 on git.golem15.com (public), tag v0.1.0 = c465414"
- "The page's one Centrifuge client with onUserEvent, shared by the chat and the presentations remote"
- "vue-demo-app /chat, the floating chat panel, the Chat tour card, EN/PL"
- "check-local chat (check-chat.cjs), tour with seven cards, smoke.sh /chat"
affects: [20-04, 20-05, 20-06]
actuals:
tokens: 80100 # chars/4: layer tracked files without the lockfile (279087) + vue-demo-app diff 44a5229..3fe8018 without the lockfile (30546) + sm-demo-app scripts diff f4ed2c2..b4fc7dc (10832)
tasks: 6
commits: 0 # MEASURED in summercms.go before this SUMMARY commit (docs only here); code commits: vue-chat-layer 4, vue-demo-app 4, sm-demo-app 2
plan_head_before: baa0f20eb2cd316cb3424fe6b4fd957d079713c0 # summercms.go
plan_head_after: baa0f20eb2cd316cb3424fe6b4fd957d079713c0 # summercms.go before this SUMMARY commit
tech-stack:
added:
- "vue-chat-layer (new shared Nuxt layer) with centrifuge 5.7.3 and @nuxtjs/i18n 10.4.0; vitest 3.2.7, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3 as dev deps (all versions already pinned in the workspace)"
patterns:
- "Store as a composable over useState keys prefixed chat:, one reactive store per Nuxt app (no Pinia)"
- "Auth hook $chatAuth {token, refresh, unauthorized, userId?}; pure Nuxt-free client in app/lib, imported explicitly"
- "One realtime client per page; other layers get user:{me} events through onUserEvent"
key-files:
created:
- ../vue-chat-layer/nuxt.config.ts
- ../vue-chat-layer/README.md
- ../vue-chat-layer/app/lib/client.ts
- ../vue-chat-layer/app/lib/realtime.ts
- ../vue-chat-layer/app/lib/linkEmbeds.ts
- ../vue-chat-layer/app/composables/useChat.ts
- ../vue-chat-layer/app/components/Chat/FloatingPanel.vue
- ../vue-chat-layer/app/components/Chat/CommandPicker.vue
- ../vue-chat-layer/app/components/Chat/ThreadPane.vue
- ../vue-chat-layer/app/assets/css/chat-tokens.css
- ../vue-chat-layer/tests/smoke.test.ts
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
- ../sm-demo-app/scripts/check-chat.cjs
modified:
- ../sm-demo-app/vue-demo-app/package.json
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/lib/realtime.ts
- ../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts
- ../sm-demo-app/vue-demo-app/vitest.config.ts
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/smoke.sh
key-decisions:
- "Task 5 checkpoint answer: release. golem15/vue-chat-layer created public with tea (same visibility as vue-faq-layer), main pushed, annotated tag v0.1.0 on c465414"
- "The layer reads the caller's user id from the JWT's numeric sub claim; an optional $chatAuth.userId() overrides it"
- "vue-demo-app chatAuth.refresh posts to POST /_user/api/v1/refresh and stores the rotated token through lib/api/client.ts tokens (document.cookie)"
- "Ported libs keep bm's signatures with optional localized copy and setFormatLocale (defaults pl-PL / Europe/Warsaw), so bm's tests port unchanged in plan 06"
- "Slash command answers render through renderChatText, the only v-html besides message bodies"
patterns-established:
- "A layer's realtime client is re-exported by the app (export * from 'vue-chat-layer/app/lib/realtime'); vitest inlines the layer and dedupes centrifuge"
requirements-completed: [SC-2, D-01, D-02, D-03, D-04, D-05, D-07, D-20, D-22, D-23]
coverage:
- id: D1
description: "A logged-in visitor opens #general on /chat and a message sent from the composer appears; anonymous /chat has the register link to /register?next=/chat and no chat"
requirement: "D-02, D-04"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-chat.cjs (check-local.sh chat)"
status: pass
- kind: smoke
ref: "../sm-demo-app/scripts/smoke.sh (/chat in the page loop, register prompt, no chat in the HTML)"
status: pass
human_judgment: false
- id: D2
description: "The chat client sends the bearer, refreshes once on 401, calls unauthorized after a failed refresh, maps 422 to field errors"
requirement: "D-01"
verification:
- kind: unit
ref: "../vue-chat-layer/tests/smoke.test.ts"
status: pass
human_judgment: false
- id: D3
description: "One shared Centrifuge client: the presentations remote and the chat ride one socket; bm's realtime test passes against it"
requirement: "SC-2"
verification:
- kind: unit
ref: "../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts"
status: pass
- kind: e2e
ref: "check-local.sh realtime remote"
status: pass
human_judgment: false
- id: D4
description: "Live delivery, /demo command (ephemeral, nothing posted), unknown command row, link card, reaction, thread reply and live thread reply, pins, attachment upload and send, DM from the picker, edit, delete, PL UI, floating panel badge and DM reply, panel hidden on /chat and decks"
requirement: "SC-2, D-02, D-03, D-05"
verification:
- kind: manual
ref: "Playwright probes against the local stack with two members (scratchpad live.cjs, features.cjs, floating.cjs)"
status: pass
human_judgment: true
- id: D5
description: "Tour has seven cards with Chat (EN) and Czat (PL); vue-demo-app builds and tests against the tag"
requirement: "D-03, D-01"
verification:
- kind: e2e
ref: "check-local.sh chat tour after the tag pin"
status: pass
- kind: build
ref: "pnpm --dir ../sm-demo-app/vue-demo-app test (209) && run build"
status: pass
human_judgment: false
duration: 52min
completed: 2026-10-31
status: complete
---
# Phase 20 Plan 03: vue-chat-layer v0.1.0 and chat on the demo site Summary
**vue-chat-layer v0.1.0 is released on git.golem15.com. It carries bm's chat ported to a Pinia-free Nuxt layer and widened to several channels, threads, pins, link cards and a slash command picker. The demo site now has /chat for members, a register prompt for visitors, a floating DM panel on every page and a Chat tour card, in EN and PL.**
## Performance
- **Duration:** about 52 min
- **Started:** 2026-10-31T12:27Z
- **Completed:** 2026-10-31T13:19Z
- **Tasks:** 6/6. Task 5 was the checkpoint, answered "release".
- **Files:** vue-chat-layer has 52 tracked files without the lockfile. vue-demo-app has 13 files changed (436 insertions, 147 deletions). sm-demo-app scripts have 3 files changed.
## The layer's public API (v0.1.0)
**Runtime config:**
| Key | Env | Purpose |
|-----|-----|---------|
| `chat.apiBase` | `NUXT_CHAT_API_BASE` | Server-side base of the chat API |
| `public.chat.apiBase` | | `/_chat/api/v1` |
| `public.chat.channelsApiBase` | | `/_chatchannels/api/v1` |
| `public.chat.botsApiBase` | | `/_chatbots/api/v1` |
| `public.chat.hostApiBase` | | `''`. With an empty value the chat does not start, and a console error names the key |
**Hook:** `$chatAuth` with `token()`, `refresh()`, `unauthorized()` and an optional `userId()`.
**Host contract:** `GET {hostApiBase}/chat` and `GET {hostApiBase}/chat/members`. The README documents both with a neutral `acme` host.
**Slots:**
- `conversation-actions`, with `{ conversation }`. It renders only for a DM, in `ChatHeader`, `ChatPanel`, `ChatPage` and `ChatFloatingPanel`.
- `sidebar-footer`, in `ChatChannelList` and `ChatPage`.
**Components:** `ChatPage`, `ChatFloatingPanel` (prop `chatPath`), `ChatChannelList`, `ChatDmList`, `ChatPanel`, `ChatHeader`, `ChatMessageList`, `ChatMessage`, `ChatComposer`, `ChatThreadPane`, `ChatPinsPanel`, `ChatReactionBar`, `ChatStagingChips`, `ChatDeleteDialog`, `ChatMemberPicker`, `ChatMentionPopup`, `ChatCommandPicker`, `ChatLinkEmbed`, `ChatAvatar`, `ChatIcon` and `ChatNotices`.
**Composables:** `useChat`, `useChatApi` (with `useChatAuth` and `useChatConfig`) and `useChatNotice`.
**Library exports:**
- `client.ts`: `createChatClient`, `ChatApiError`, `userIdFromToken`.
- `realtime.ts`: `REALTIME_WS_PATH`, `connect`, `subscribe`, `presence`, `openChannel`, `onUserEvent`, `dispatchUserEvent`, `setRealtimeAuth`, `disconnect`, `onConnection`, `onUnavailable`.
- `linkEmbeds.ts`: `linkify`, `messageUrls`, `registerEmbedRenderer`, `embedRendererFor`.
- The ported `chatEvents`, `chatFormat`, `chatText` and `chatUnread`, plus `format` (`setFormatLocale`), `attachments`, `markdown`, `media` and `tabs`.
**Copy:** EN and PL under `chat.*`, 131 keys. PL covers every EN key.
## What was stripped from bm and why
- **bm's embeds and the embed picker were dropped** (`deadline`, `class` and `reading`, plus `EmbedPicker`, `EmbedCard`, `lib/embeds.ts` and the paste-to-embed logic). They are bm entities. In their place:
- A generic link card shows the host and path, with no fetch.
- `registerEmbedRenderer` lets a host render its own entity links (the D-01 extension point).
- **The single `# ogólny` model became `channels[]`, `activeId` and `kind`.** bm's "Czat roku" tab layout became a sidebar plus pane. Threads, pins and the command picker are new.
- **The Pinia stores went away.** `useChat` became `useState` and `useToast` became `useChatNotice`.
- **The bm cached membership flag in localStorage and its BM bootstrap paths were removed.** Every string went to i18n.
## Task Commits
| # | Repo | Commit | Message |
|---|------|--------|---------|
| 1 | vue-chat-layer | fa6f995 | feat: chat layer tracer: bootstrap, channel list, messages, composer |
| 1 | vue-demo-app | 5b7648f | feat: /chat page on vue-chat-layer |
| 1 | sm-demo-app | b6ece43 | test: check-chat browser check |
| 2 | vue-chat-layer | 5d58153 | feat: port the chat store, realtime client and components |
| 2 | vue-demo-app | ae3dbfc | refactor: share the chat layer's realtime client |
| 3 | vue-chat-layer | 269c75b | feat: threads, pins, reactions, attachments, link embeds and the command picker |
| 4 | vue-chat-layer | c465414 | feat: floating chat panel |
| 4 | vue-demo-app | 8636aa3 | feat: floating chat panel, Chat tour card |
| 4 | sm-demo-app | b4fc7dc | test: tour expects the Chat card, smoke covers /chat |
| 6 | vue-chat-layer | tag v0.1.0 (1e6a907, peels to c465414) | vue-chat-layer v0.1.0 |
| 6 | vue-demo-app | 3fe8018 | feat: pin vue-chat-layer v0.1.0 |
**Pushed:** only vue-chat-layer `main` and the tag `v0.1.0`. They went to the new public repository `git@git.golem15.com:golem15/vue-chat-layer.git`, created with `tea repo create`. `git ls-remote origin 'refs/tags/v0.1.0^{}'` equals HEAD c465414.
**Not pushed:** vue-demo-app and sm-demo-app (plan 05). sm-demo-app shows the moved `vue-demo-app` submodule pointer as uncommitted; plan 05 bumps it.
## Checkpoint answer
Task 5: **release**. The user gave the go-ahead to create the repository, push main and tag v0.1.0.
## Verification
- **Layer:** `pnpm test` passes 6/6, and `pnpm build:playground` builds.
- **vue-demo-app:** `pnpm test` passes 209/209 and `pnpm run build` builds, both against the link pin and against the tag.
- **check-local.sh:** these runs all printed ok.
- `chat` (Task 1)
- `chat realtime remote` (Task 2)
- `chat` (Task 3)
- `tour chat realtime remote` (Task 4)
- `chat tour` after the tag pin (Task 6)
- **smoke.sh:** `smoke: ok` with /chat included.
- **Manual (Task 3 criterion):** `/demo` typed in the #general composer opened the picker, and Enter showed the ephemeral answer. The channel had 18 messages before and 18 after, so nothing was posted.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical] The caller's user id**
- **Found during:** Task 2.
- **Issue:** The demo bootstrap and `/chat/members` do not include the caller. The store needs the id for `user:{me}` and for telling its own messages apart.
- **Fix:** `userIdFromToken` reads the JWT's numeric `sub` claim. An optional `$chatAuth.userId()` overrides it. Documented in the README. Commit 5d58153.
**2. Refresh endpoint**
- useDemoAuth has no refresh.
- `chatAuth.refresh` calls `POST /_user/api/v1/refresh` with the bearer token. The user plugin answers the rotated token in the body.
- The token is stored through `lib/api/client.ts` `tokens` (`document.cookie`), because the hook runs outside a component's setup, where `useCookie` fails. Commits 5b7648f and ae3dbfc.
**3. [Rule 3 - Blocking] Realtime test and vitest config**
- vue-demo-app's `app/lib/realtime.test.ts` is now bm's current realtime test against the shared client. Two adjustments go beyond import paths:
- The token route is a stubbed `fetch` instead of a mocked `@/api/client`, because the layer fetches the token itself.
- The 503 case asserts `{status: 503}`, since the error is now `RealtimeTokenError`.
- `vitest.config.ts` (outside Task 2's file list) got `resolve.dedupe: ['centrifuge']` and `test.server.deps.inline: ['vue-chat-layer']`. With these the mock applies, and the tag-installed layer's TypeScript is transformed. Commit ae3dbfc.
**4. Extra layer files and the shape of the ported libs**
- Two files are outside the plan's list:
- `app/lib/markdown.ts`: `escapeHtml`, `safeUrl` and `formatSpans`, for chatText.
- `app/lib/attachments.ts`: `checkFiles`.
- The ported libs keep bm's signatures, with optional localized copy. `setFormatLocale` configures the format locale; its defaults stay pl-PL and Europe/Warsaw, so bm's tests port unchanged in plan 06. The components set the app's locale and the browser's time zone.
- `chatEvents` adds an optional `rootId` to created events, so replies stay out of the main list.
**5. Ephemeral answers**
- Slash command answers render through `renderChatText`. This keeps the prohibition: `v-html` is used only for the escaped renderer output.
- `linkify` is exported but not used by the components.
**6. Naming and copy**
- The app's `chat.*` keys (`eyebrow`, `title`, `lead`, `slashHint`, `anonymous`, `registerToChat`) avoid the layer's `chat.*` tree. The layer's own title key is `chat.name`.
- `{'@'}` escapes @ in the app copy, because a bare @ is vue-i18n link syntax.
- /chat also has a Log in link next to the register link.
**7. Dev-stack note**
- Running `nuxt build` while `nuxt dev` is up makes the dev server loop on "Another Nuxt dev is already running".
- The stack was restarted with `dev.sh down` and `dev.sh up` after each build.
**8. Pins**
- The visitor was allowed to pin in #general, and the pin succeeded.
- The refused-pin path (a notice, then the action hidden for the session) is in the code but was not exercised.
**Total deviations:** 8. **Impact:** none on the plan goal.
## Known Stubs
None. Link cards are generic by design (D-01; there is no unfurl backend).
## Issues Encountered
- **vue-tsc on the playground** (run with summercms.go/admin's vue-tsc, not a gate) reports only `noUncheckedIndexedAccess` errors (the "possibly undefined" kind). They are mostly in the modules ported from bm and come from the source. Builds don't typecheck.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: supply-chain | ../sm-demo-app/vue-demo-app/package.json | New git dependency vue-chat-layer pinned by tag v0.1.0 (commit c465414 in the lockfile), as planned in T-20-18 |
## Next Phase Readiness
- **Plan 04** (vue-chatvideo-layer) can extend `vue-chat-layer#v0.1.0`:
- Call button: put it in the `conversation-actions` slot, which renders for DMs only.
- `video:*` events: listen through `onUserEvent`.
- Session channels: subscribe through the shared `subscribe`.
- **Plan 05** needs to know:
- vue-demo-app is ready to push. Its `package.json` and lockfile pin the tag, with no local path.
- The sm-demo-app submodule pointer bump is still to do.
## Self-Check: PASSED
- The listed files exist in their repos: vue-chat-layer, vue-demo-app and sm-demo-app scripts.
- Commits exist:
- vue-chat-layer: fa6f995, 5d58153, 269c75b, c465414.
- vue-demo-app: 5b7648f, ae3dbfc, 8636aa3, 3fe8018.
- sm-demo-app: b6ece43, b4fc7dc.
- The remote tag v0.1.0 peels to c465414.

View File

@@ -0,0 +1,517 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 04
type: execute
wave: 4
depends_on: ["20-03"]
files_modified:
- ../vue-forum-layer/.gitignore
- ../vue-forum-layer/.nvmrc
- ../vue-forum-layer/package.json
- ../vue-forum-layer/pnpm-lock.yaml
- ../vue-forum-layer/pnpm-workspace.yaml
- ../vue-forum-layer/tsconfig.json
- ../vue-forum-layer/nuxt.config.ts
- ../vue-forum-layer/README.md
- ../vue-forum-layer/.playground/nuxt.config.ts
- ../vue-forum-layer/.playground/app/app.vue
- ../vue-forum-layer/app/utils/forumClient.ts
- ../vue-forum-layer/app/composables/useForumApi.ts
- ../vue-forum-layer/app/composables/useForum.ts
- ../vue-forum-layer/app/components/Forum/Page.vue
- ../vue-forum-layer/app/components/Forum/CategoryList.vue
- ../vue-forum-layer/app/components/Forum/ThreadList.vue
- ../vue-forum-layer/app/components/Forum/Thread.vue
- ../vue-forum-layer/app/components/Forum/PostEditor.vue
- ../vue-forum-layer/app/assets/css/forum-base.css
- ../vue-forum-layer/i18n/locales/en.json
- ../vue-forum-layer/i18n/locales/pl.json
- ../vue-forum-layer/tests/client.test.ts
- ../vue-userfriends-layer/.gitignore
- ../vue-userfriends-layer/.nvmrc
- ../vue-userfriends-layer/package.json
- ../vue-userfriends-layer/pnpm-lock.yaml
- ../vue-userfriends-layer/pnpm-workspace.yaml
- ../vue-userfriends-layer/tsconfig.json
- ../vue-userfriends-layer/nuxt.config.ts
- ../vue-userfriends-layer/README.md
- ../vue-userfriends-layer/.playground/nuxt.config.ts
- ../vue-userfriends-layer/.playground/app/app.vue
- ../vue-userfriends-layer/app/utils/friendsClient.ts
- ../vue-userfriends-layer/app/composables/useFriendsApi.ts
- ../vue-userfriends-layer/app/composables/useFriends.ts
- ../vue-userfriends-layer/app/components/Friends/Page.vue
- ../vue-userfriends-layer/app/components/Friends/List.vue
- ../vue-userfriends-layer/app/components/Friends/Requests.vue
- ../vue-userfriends-layer/app/components/Friends/Groups.vue
- ../vue-userfriends-layer/app/components/Friends/AddFriend.vue
- ../vue-userfriends-layer/app/assets/css/friends-base.css
- ../vue-userfriends-layer/i18n/locales/en.json
- ../vue-userfriends-layer/i18n/locales/pl.json
- ../vue-userfriends-layer/tests/client.test.ts
- ../vue-chatvideo-layer/.gitignore
- ../vue-chatvideo-layer/.nvmrc
- ../vue-chatvideo-layer/package.json
- ../vue-chatvideo-layer/pnpm-lock.yaml
- ../vue-chatvideo-layer/pnpm-workspace.yaml
- ../vue-chatvideo-layer/tsconfig.json
- ../vue-chatvideo-layer/nuxt.config.ts
- ../vue-chatvideo-layer/README.md
- ../vue-chatvideo-layer/.playground/nuxt.config.ts
- ../vue-chatvideo-layer/.playground/app/app.vue
- ../vue-chatvideo-layer/app/lib/videoClient.ts
- ../vue-chatvideo-layer/app/lib/callMachine.ts
- ../vue-chatvideo-layer/app/composables/useCall.ts
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
- ../vue-chatvideo-layer/i18n/locales/en.json
- ../vue-chatvideo-layer/i18n/locales/pl.json
- ../vue-chatvideo-layer/tests/callMachine.test.ts
- ../sm-demo-app/vue-demo-app/package.json
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
- ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue
- ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue
- ../sm-demo-app/vue-demo-app/app/pages/friends.vue
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/smoke.sh
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/README.md
- .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md
autonomous: false
requirements: [SC-2, D-11, D-12, D-13, D-14, D-15, D-16, D-20, D-21, D-22, D-23]
estimate:
tokens: 380000
raw_tokens: 380000
tasks: 7
confidence: low
must_haves:
truths:
- "D-14, D-21: /forum shows a member the seeded EN and PL categories and their threads; a thread page lists posts as the server's content_html; a member creates a thread, replies, edits own posts and deletes own posts (moderators any post, D-22); an anonymous visitor on /forum and on a thread URL sees a description and a register-to-join link (/register?next=...), and no forum API call is made without a JWT."
- "D-15: /friends shows a member their friends, incoming and sent requests (accept, reject, cancel, unfriend) and friend groups (list, create, join, leave, members); a member sends a request to another member chosen from the app-provided directory; /friends is linked from the chat sidebar (sidebar-footer slot) and from the account area (header link and /account/security)."
- "D-11, D-23: the call button (ChatvideoCallButton) appears only in DM conversations through vue-chat-layer's conversation-actions slot; there is no call button in channels; the server is not changed to refuse channel calls."
- "D-13: /call/{conversation} is a minimal call page: own camera, remote tiles, mute, camera toggle and leave, built on livekit-client loaded with a dynamic import; an incoming video:call:incoming event (received through vue-chat-layer's onUserEvent, no second user:{me} subscription) shows an accept/decline prompt on every page for members."
- "D-12: the call page shows the D-12 caps' effects (a started call ends at the server's max_duration; a fifth participant is refused by LiveKit's room cap) without client-side limits of its own."
- "D-16: herald runs with VAPID keys from .env (SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, __PRIVATE_KEY, __SUBJECT; dev.sh generates them with herald:vapid-generate, never printing them) and there is no subscribe UI; /_herald is not added to vue-demo-app goPaths."
- "The homepage tour has Forum, Friends and Calls cards next to Chat (ten cards, EN and PL)."
- "livekit-client is installed only after the blocking human-verify checkpoint, at an exact version the user confirmed."
- "After the Task 6 go-ahead, vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer exist on git.golem15.com with tag v0.1.0 each, and vue-demo-app pins all three by git+https tag in a committed package.json and pnpm-lock.yaml."
artifacts:
- path: "../vue-forum-layer/app/components/Forum/Page.vue"
provides: "categories and thread list of one forum context"
contains: "contextType"
- path: "../vue-userfriends-layer/app/components/Friends/Page.vue"
provides: "friends, requests and groups"
contains: "Requests"
- path: "../vue-chatvideo-layer/app/lib/callMachine.ts"
provides: "pure call state machine (idle, starting, connecting, connected, ended, failed)"
contains: "connected"
- path: "../vue-chatvideo-layer/app/composables/useCall.ts"
provides: "LiveKit room lifecycle over the chatvideo API, dynamic import of livekit-client"
contains: "import('livekit-client')"
- path: "../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue"
provides: "/call/{conversation} page"
contains: "ChatvideoCall"
- path: ".planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md"
provides: "API coverage decisions for livekit-client, LiveKit server, Centrifugo and Web Push"
contains: "livekit-client"
key_links:
- from: "../sm-demo-app/vue-demo-app/app/pages/chat.vue"
to: "../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue"
via: "template #conversation-actions renders ChatvideoCallButton (DM only, D-23)"
pattern: "conversation-actions"
- from: "../vue-chatvideo-layer/app/composables/useIncomingCalls.ts"
to: "../vue-chat-layer/app/lib/realtime.ts"
via: "onUserEvent receives video:call:incoming from the chat store's user:{me} subscription"
pattern: "onUserEvent"
- from: "../vue-chatvideo-layer/app/composables/useCall.ts"
to: "../sm-demo-app/plugins/golem15/chatvideo/api/video.go"
via: "POST /_chatvideo/api/v1/conversations/{id}/video/start or /join gives {livekit_url, token}"
pattern: "video/(start|join)"
- from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
to: "../vue-forum-layer/app/composables/useForumApi.ts"
via: "provide forumAuth and userfriendsAuth token getters (chatvideo reuses chatAuth)"
pattern: "forumAuth"
prohibitions:
- statement: "None of the three layers names a consuming application or carries a default host path."
status: unresolved
verification: automated
- statement: "Forum post HTML is rendered only from the server's content_html; user Markdown is never rendered client-side."
status: unresolved
verification: judgment
- statement: "livekit-client is not added to any package.json before the Task 3 checkpoint is approved."
status: unresolved
verification: judgment
- statement: "No push, repository creation or tag before the Task 6 go-ahead; vue-demo-app and sm-demo-app are not pushed (plan 05)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): the demo's public Nuxt site shows the shared plugins working through shared layers, video calls included.
This plan's slice (plan 4 of 6, wave 4, per D-20): three new shared layers at v0.1.0 (`vue-forum-layer`, `vue-userfriends-layer`, `vue-chatvideo-layer`), their pages and tour cards in vue-demo-app, and herald's VAPID configuration without UI.
**Wave note (deviation from D-20, recorded):** D-20 lets plans 03 and 04 run in parallel after plan 02. This plan still depends on plan 03 and runs after it, for two reasons:
1. It consumes plan 03's vue-chat-layer outputs: vue-chatvideo-layer imports the shared realtime client (`onUserEvent`, `subscribe`) and renders its call button in vue-chat-layer's DM-only `conversation-actions` slot (D-23), and the friends link goes into its `sidebar-footer` slot.
2. Both plans edit the same vue-demo-app files (package.json, pnpm-lock.yaml, nuxt.config.ts, app/plugins/auth.ts, app/pages/index.vue, the locales) in one checkout. Parallel execution runs without worktrees, so the two plans would collide in the same wave.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Layer shape:** like vue-faq-layer and vue-paymentgateway-layer. A Nuxt-free client in `app/utils/*Client.ts` (tested with `node --test`), an auth-hook composable, components with a directory prefix (`Forum*`, `Friends*`, `Chatvideo*`), EN/PL strings through `i18n:registerModule`, and no pages or route rules: the app owns routes and wraps client-side parts in ClientOnly.
- **Auth hooks:** `$forumAuth` and `$userfriendsAuth` are token getters, the vue-paymentgateway-layer shape. vue-chatvideo-layer reuses vue-chat-layer's `$chatAuth`, because it has a peer dependency on that layer.
- **Forum context:** props `contextType` and `contextId`; vue-demo-app passes `demo` and `1`. Thread URLs are built by an app-supplied `threadPath(id)` prop. Markdown is written in a textarea with the server preview endpoint (`POST /contexts/{type}/{id}/preview`); nothing is rendered client-side.
- **Friend directory:** the layer takes an optional `$userfriendsDirectory(): Promise<{id, name}[]>` hook for the add-friend picker and falls back to a username field (the API accepts user_id, username or email). vue-demo-app provides the directory from `GET /_demo/api/v1/chat/members`.
- **chatvideo and chat:** vue-chatvideo-layer lists vue-chat-layer v0.1.0 as a peer dependency and dev dependency, without `extends`. The app extends both, so the chat layer is not loaded twice. It imports `onUserEvent` and `subscribe` from `vue-chat-layer/app/lib/realtime`.
- **Call flow:** the call button starts (`video/start`) and navigates to `/call/{conversation}`. The call page joins (`video/join`) when it was not the starter and connects with the returned `livekit_url` and `token`. Leave calls `video/leave` then `room.disconnect()`. An incoming call prompt offers Accept (navigate) and Decline (`video/leave`; the service publishes video:call:declined for a participant who never joined; Task 4 confirms this in video_service.go). Screen share stays off (D-13 minimal, `screen_share: false`).
- **Profile link (D-15):** the demo has no profile page. The account area stands for it: a "Friends" header link for members next to "Security", and a link on /account/security.
- **Tour:** ten cards (Journal, Presentations, Quizzes, Chat, Forum, Friends, Calls, Payments, FAQ, Accounts). The Calls card links to /chat with text saying calls start from a DM.
- **Development pins:** `link:` dependencies to the three local checkouts until Task 7 swaps them for tags, as in plan 03.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- Repository `vue-forum-layer` v0.1.0:
- components `ForumPage`, `ForumCategoryList`, `ForumThreadList`, `ForumThread`, `ForumPostEditor`;
- composables `useForum`, `useForumApi`; client `createForumClient`;
- runtime keys `forum.apiBase` (`NUXT_FORUM_API_BASE`) and `public.forum.apiBase`; hook `$forumAuth`; i18n `forum.*`.
- Repository `vue-userfriends-layer` v0.1.0:
- components `FriendsPage`, `FriendsList`, `FriendsRequests`, `FriendsGroups`, `FriendsAddFriend`;
- composables `useFriends`, `useFriendsApi`; client `createFriendsClient`;
- runtime keys `userfriends.apiBase` and `public.userfriends.apiBase`; hooks `$userfriendsAuth` and `$userfriendsDirectory`; i18n `friends.*`.
- Repository `vue-chatvideo-layer` v0.1.0:
- components `ChatvideoCall`, `ChatvideoTile`, `ChatvideoCallButton`, `ChatvideoIncomingCall`;
- composables `useCall`, `useIncomingCalls`; lib `createVideoClient`, `callMachine` (`initialCallState`, `transition`);
- runtime key `public.chatvideo.apiBase`; prop `callPath`; i18n `chatvideo.*`; dependency `livekit-client` at the confirmed exact version.
- vue-demo-app:
- pages `/forum`, `/forum/t/[id]`, `/friends`, `/call/[conversation]`;
- ChatvideoIncomingCall in the default layout; the call button in /chat's conversation-actions slot; the friends link in /chat's sidebar-footer slot, the header and /account/security;
- tour cards forum, friends and calls; goPaths `/_forum`, `/_userfriends`, `/_chatvideo`; hooks `forumAuth`, `userfriendsAuth`, `userfriendsDirectory`.
- sm-demo-app: check-local checks `forum`, `friends`, `call`, `herald`; tour expects ten cards; smoke.sh pages /forum and /friends; dev.sh VAPID generation; env keys `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in env.example).
- `.planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` (API coverage matrix).
<objective>
Build and release the forum, friends and video-call layers, put their pages and tour cards on the demo site, and configure herald's VAPID keys without UI.
Purpose: SC-2 (video calls and the other plugins visible through shared layers), D-11, D-12, D-13, D-14, D-15, D-16, D-21, D-22 (forum moderation), D-23.
Output: three new repositories (pushed and tagged after the go-ahead), commits in vue-demo-app and sm-demo-app (not pushed), and the phase COVERAGE.md.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-PATTERNS.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
Repos: new ../vue-forum-layer, ../vue-userfriends-layer and ../vue-chatvideo-layer; ../sm-demo-app/vue-demo-app (submodule); ../sm-demo-app. Layer analogs: ../vue-faq-layer (structure, node --test, client in app/utils), ../vue-paymentgateway-layer (auth hook, i18n registration), ../vue-chat-layer (plan 03: slots, onUserEvent, $chatAuth).
Rules: as in plan 03 (no co-author tags, stage by path, no remote before the go-ahead, vue-demo-app committed in its own repository and not pushed, check for other sessions' edits before each commit). The local stack: `../sm-demo-app/scripts/dev.sh up` with LiveKit on 127.0.0.1:7890.
Size (accepted under D-20): seven tasks over three new layer repositories are above the usual per-plan budget, but the user confirmed exactly six plans (D-20, the lean-planning rule in CLAUDE.md), so this plan is not split. After Task 4 is committed, write a progress note into a draft `20-04-SUMMARY.md` (Tasks 1 to 4 with their commits, the approved livekit-client version, the decline endpoint, open points) so a context reset resumes from Task 5.
<interfaces>
forum API /_forum/api/v1 (JWT, forum-api 120/min): GET /contexts/{type}/{id}/categories; GET /contexts/{type}/{id}/categories/{categoryId}/threads; POST same (create thread {title, body}); GET /contexts/{type}/{id}/threads; GET /contexts/{type}/{id}/threads/{threadId}/posts; POST same (reply {body}); PUT /contexts/{type}/{id}/posts/{postId} ({body}); DELETE /contexts/{type}/{id}/posts/{postId}; DELETE /contexts/{type}/{id}/threads/{threadId}; POST /contexts/{type}/{id}/threads/{threadId}/read; GET /contexts/{type}/{id}/unread; POST /contexts/{type}/{id}/preview. Posts carry content_html (server-rendered with breakwater). 422 envelope as chat.
userfriends API /_userfriends/api/v1 (JWT; userfriends-api 120/min, community-join 10/min): GET /friends, /friends/requests, /friends/sent; POST /friends/request ({user_id} or {username} or {email}); POST /friends/requests/{id}/accept; DELETE /friends/requests/{id}; DELETE /friends/sent/{id}; DELETE /friends/{userId}; GET|POST /groups; GET|PUT|DELETE /groups/{id}; POST /groups/{id}/join, /leave; GET|POST /groups/{id}/members; DELETE /groups/{id}/members/{userId}; GET|POST|DELETE /groups/{id}/invite; GET|POST /groups/join/{code}.
chatvideo API /_chatvideo/api/v1 (JWT): POST /conversations/{id}/video/start (video-initiate 5/min) and /join give {livekit_url, token, ...}; POST /leave, /end; GET /status. Realtime: video:call:incoming on user:{id}; video:call:accepted, declined, ended and participant events on video:session:{id}.
vue-chat-layer v0.1.0 (plan 03): slots conversation-actions (DM only) and sidebar-footer on ChatPage; `onUserEvent(handler)`, `subscribe(channel, hooks)` in `vue-chat-layer/app/lib/realtime`; `$chatAuth {token, refresh, unauthorized}`.
herald: keys golem15.herald.vapid.{subject, public_key, private_key}; env SUMMER_GOLEM15__HERALD__VAPID__*; command herald:vapid-generate (--json prints {"public_key", "private_key"}; -q quiet).
vue-demo-app (after plan 03): app/pages/chat.vue (ChatPage in ClientOnly), app/layouts/default.vue (ChatFloatingPanel), app/pages/index.vue (stops with chat), app/components/TourCard.vue, app/components/SiteHeader.vue (links mine and security), app/pages/account/security.vue, app/plugins/auth.ts (paymentGatewayAuth, chatAuth).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a member opens /forum, sees the seeded categories, opens a thread and posts a reply that the server renders; an anonymous visitor gets the register prompt</name>
<precondition>The plan 03 SUMMARY records vue-chat-layer v0.1.0 on git.golem15.com; `../sm-demo-app/scripts/dev.sh up` is running; `git -C ../sm-demo-app/vue-demo-app status --porcelain` lists no file this task changes.</precondition>
<files>../vue-forum-layer/.gitignore, ../vue-forum-layer/.nvmrc, ../vue-forum-layer/package.json, ../vue-forum-layer/pnpm-lock.yaml, ../vue-forum-layer/pnpm-workspace.yaml, ../vue-forum-layer/tsconfig.json, ../vue-forum-layer/nuxt.config.ts, ../vue-forum-layer/README.md, ../vue-forum-layer/.playground/nuxt.config.ts, ../vue-forum-layer/.playground/app/app.vue, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-forum-layer/app/composables/useForumApi.ts, ../vue-forum-layer/app/composables/useForum.ts, ../vue-forum-layer/app/components/Forum/Page.vue, ../vue-forum-layer/app/components/Forum/CategoryList.vue, ../vue-forum-layer/app/components/Forum/ThreadList.vue, ../vue-forum-layer/app/components/Forum/Thread.vue, ../vue-forum-layer/app/components/Forum/PostEditor.vue, ../vue-forum-layer/app/assets/css/forum-base.css, ../vue-forum-layer/i18n/locales/en.json, ../vue-forum-layer/i18n/locales/pl.json, ../vue-forum-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue, ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
<read_first>../vue-faq-layer/{nuxt.config.ts,package.json,README.md,app/utils/client.ts,app/composables/useFaqApi.ts,tests/client.test.ts}, ../vue-paymentgateway-layer/{nuxt.config.ts,app/composables/usePaymentGatewayApi.ts}, ../sm-demo-app/plugins/golem15/forum/README.md and api/*.go (payloads), ../sm-demo-app/plugins/demo/classes/forum_access.go, ../sm-demo-app/vue-demo-app/{nuxt.config.ts,app/plugins/auth.ts,app/pages/chat.vue,app/pages/quizzes/index.vue}, ../sm-demo-app/scripts/check-local.sh (visitor_jwt, json_has, ALL_CHECKS) and smoke.sh (page loop)</read_first>
<action>Per D-14, D-21, D-22, SC-2.
1. Create ../vue-forum-layer (`git init -b main`) with the vue-faq-layer skeleton:
- package.json: name vue-forum-layer, version 0.1.0, main nuxt.config.ts, peer nuxt 4.4.8, dependency @nuxtjs/i18n 10.4.0, dev deps nuxt, vue and typescript at the workspace pins, test `node --test --test-reporter=tap tests/*.test.ts`;
- nuxt.config.ts: header comment, currentDir, runtimeConfig `forum.apiBase` (NUXT_FORUM_API_BASE) and `public.forum.apiBase: '/_forum/api/v1'`, the EN/PL i18n registration, absolute css path.
2. app/utils/forumClient.ts `createForumClient({ base, token, fetch })`: categories, threads (per category and all), posts, createThread, reply, editPost, deletePost, deleteThread, markRead, unread and preview, each building `/contexts/{type}/{id}/...` paths, plus the 422 field-error mapping. app/composables/useForumApi.ts binds it to runtime config and `$forumAuth`. useForum keeps the selected category, the threads, and the open thread and posts.
3. Components:
- ForumPage (props contextType, contextId, threadPath(id)): ForumCategoryList plus ForumThreadList with a "new thread" ForumPostEditor;
- ForumThread (props contextType, contextId, threadId): each post renders the server's content_html as raw HTML (the only raw-HTML binding in the layer), with author and date, reply editor, Edit and Delete on own posts and on all posts when the API reports manage rights (a refused delete hides the action);
- ForumPostEditor: textarea plus a server preview button.
Mark the thread read on open.
4. vue-demo-app:
- run `pnpm add vue-forum-layer@link:../../vue-forum-layer` (development pin, committed; Task 7 swaps it for the tag before any push);
- nuxt.config.ts: extends gains 'vue-forum-layer'; goPaths gains '/_forum'; `forum.apiBase` comes from `api`;
- auth.ts provides `forumAuth`;
- app/pages/forum/index.vue and forum/t/[id].vue: for members, ClientOnly ForumPage and ForumThread with context demo/1 and `threadPath: id => '/forum/t/' + id`; for anonymous visitors, a server-rendered description and a register link with next set to the current path (D-21, the chat page pattern). useSeoMeta on both;
- EN/PL copy: forum.title, forum.lead and forum.registerToJoin.
5. check-local.sh `check_forum`:
- with the visitor JWT, GET /_forum/api/v1/contexts/demo/1/categories answers 200 with at least three categories;
- POST a reply to the first thread answers 201 and its content_html holds a rendered paragraph;
- GET $NUXT/forum without a cookie holds the register link with next=%2Fforum.
Add it to ALL_CHECKS. smoke.sh adds /forum to the page loop.
6. tests/client.test.ts: URL building for every method, the Bearer header, and the 422 mapping.
Commit the layer (`feat: forum layer: categories, threads and Markdown posts`), vue-demo-app (package.json and pnpm-lock.yaml with the link pin, pages, config, auth and locales) and sm-demo-app scripts separately.</action>
<verify>
<automated>pnpm --dir ../vue-forum-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/dev.sh up &amp;&amp; ../sm-demo-app/scripts/check-local.sh forum</automated>
<fails_when>Non-zero exit: the forum client tests fail, the Nuxt build fails, the stack does not start, or check-local prints "check-local: forum failed" (categories missing, the reply not rendered, or no register link for anonymous visitors).</fails_when>
</verify>
<acceptance_criteria>
- `grep -rniE 'golem15\.demo|sm-demo|demo/1' ../vue-forum-layer/app ../vue-forum-layer/nuxt.config.ts ../vue-forum-layer/README.md | wc -l` prints 0.
- `grep -rn 'v-html' ../vue-forum-layer/app/components | grep -vc 'content_html'` prints 0.
- `grep -c "'/_forum'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 1.
</acceptance_criteria>
<done>Members read and post in the seeded forum on the demo site through a new shared layer; anonymous visitors are invited to register.</done>
</task>
<task type="auto">
<name>Task 2: Friends: list, requests and groups on /friends, linked from the chat sidebar and the account area</name>
<files>../vue-userfriends-layer/.gitignore, ../vue-userfriends-layer/.nvmrc, ../vue-userfriends-layer/package.json, ../vue-userfriends-layer/pnpm-lock.yaml, ../vue-userfriends-layer/pnpm-workspace.yaml, ../vue-userfriends-layer/tsconfig.json, ../vue-userfriends-layer/nuxt.config.ts, ../vue-userfriends-layer/README.md, ../vue-userfriends-layer/.playground/nuxt.config.ts, ../vue-userfriends-layer/.playground/app/app.vue, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../vue-userfriends-layer/app/composables/useFriendsApi.ts, ../vue-userfriends-layer/app/composables/useFriends.ts, ../vue-userfriends-layer/app/components/Friends/Page.vue, ../vue-userfriends-layer/app/components/Friends/List.vue, ../vue-userfriends-layer/app/components/Friends/Requests.vue, ../vue-userfriends-layer/app/components/Friends/Groups.vue, ../vue-userfriends-layer/app/components/Friends/AddFriend.vue, ../vue-userfriends-layer/app/assets/css/friends-base.css, ../vue-userfriends-layer/i18n/locales/en.json, ../vue-userfriends-layer/i18n/locales/pl.json, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/pages/friends.vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/pages/account/security.vue, ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/smoke.sh</files>
<read_first>../sm-demo-app/plugins/golem15/userfriends/README.md and api/friends.go and api/groups.go (bodies, 422 behaviour), ../vue-forum-layer (Task 1, same skeleton), ../vue-chat-layer/README.md (sidebar-footer slot), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/components/SiteHeader.vue,app/pages/account/security.vue,app/plugins/auth.ts}, ../sm-demo-app/plugins/demo/chat_api.go (members endpoint)</read_first>
<action>Per D-15.
- Create ../vue-userfriends-layer with the Task 1 skeleton (runtime `userfriends.apiBase`, `public.userfriends.apiBase: '/_userfriends/api/v1'`).
- app/utils/friendsClient.ts `createFriendsClient`: friends, incoming, sent, request({user_id} or {username}), accept, reject, cancel, unfriend, groups, group, createGroup, updateGroup, deleteGroup, join, leave, members, kick, invite, joinByCode. useFriendsApi binds `$userfriendsAuth`.
- Components:
- FriendsList: friends with Unfriend;
- FriendsRequests: incoming with Accept and Reject, sent with Cancel;
- FriendsAddFriend: picks from `$userfriendsDirectory()` when provided, else a username field; the generic 422 shows the server message;
- FriendsGroups: my groups, create (name, visibility as the API allows), join by code, leave, member list;
- FriendsPage: all four in sections.
- vue-demo-app:
- pnpm link dependency; extends 'vue-userfriends-layer'; goPaths '/_userfriends'; runtime config;
- auth.ts provides `userfriendsAuth` and `userfriendsDirectory` (GET /_demo/api/v1/chat/members with the JWT, mapped to {id, name: name plus surname});
- app/pages/friends.vue: ClientOnly FriendsPage for members, the register prompt for anonymous visitors;
- chat.vue passes `<template #sidebar-footer>` with a NuxtLink to /friends;
- SiteHeader shows "Friends" for members next to Security, and account/security.vue links to /friends;
- EN/PL copy.
- check-local.sh `check_friends`, repeatable without touching seed data:
- with the visitor JWT, GET /_userfriends/api/v1/friends/requests lists the seeded pending request (read-only);
- two fresh users from the throwaway registration helper (extend it so the caller also gets the e-mail it registered): A sends POST /friends/request with {email: B's e-mail} and gets 2xx, B finds the request in GET /friends/requests and accepts it with 2xx, and A's GET /friends lists B.
Add it to ALL_CHECKS. smoke.sh adds /friends.
- tests/client.test.ts: URL building, Bearer header, 422 mapping.
Commit the layer, vue-demo-app (with the link pin) and the scripts separately.</action>
<verify>
<automated>pnpm --dir ../vue-userfriends-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh friends forum chat</automated>
<fails_when>Non-zero exit: the friends client tests or the build fail, or check-local reports friends, forum or chat failed (the seeded request missing, accept refused, or the chat page broken by the slot).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'sidebar-footer' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
- `grep -c "to=\"/friends\"" ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue` prints 1.
- `grep -rniE 'golem15\.demo|sm-demo|_demo/api' ../vue-userfriends-layer/app ../vue-userfriends-layer/nuxt.config.ts ../vue-userfriends-layer/README.md | wc -l` prints 0.
</acceptance_criteria>
<done>Members manage friends, requests and friend groups on /friends, reachable from the chat sidebar and the account area.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Package legitimacy check before installing livekit-client</name>
<read_first>20-RESEARCH.md Package Legitimacy Audit (livekit-client flagged [SUS] too-new for its latest release; assumption A7), the output Claude prints before asking: `npm view livekit-client versions --json | tail -15`, `npm view livekit-client time --json` for the last five versions, `npm view livekit-client repository.url maintainers dist-tags scripts --json`, and the proposed exact version (the newest release at least 7 days old)</read_first>
<acceptance_criteria>
- The user approved a named exact version (recorded in the SUMMARY) or rejected the package; Task 4 installs only that version with `--save-exact`.
</acceptance_criteria>
<what-built>Nothing is installed yet. Claude proposes an exact livekit-client version for vue-chatvideo-layer, the client SDK the minimal call page (D-13) is built on.</what-built>
<how-to-verify>
1. Open https://www.npmjs.com/package/livekit-client and check that the publisher and maintainers are the LiveKit organization and the repository is github.com/livekit/client-sdk-js.
2. Check that the proposed version exists, is at least 7 days old and that `scripts` shows no install or postinstall hook.
3. Check the client SDK's server compatibility notes for LiveKit server 1.13.1 (the rome and local binary).
</how-to-verify>
<resume-signal>Reply "approved <version>" or "rejected" with the reason.</resume-signal>
</task>
<task type="auto">
<name>Task 4: Video calls from DMs: the call button, the minimal call page and incoming-call prompts</name>
<precondition>Task 3 answered "approved <version>"; LiveKit answers on http://127.0.0.1:7890/ (dev stack).</precondition>
<files>../vue-chatvideo-layer/.gitignore, ../vue-chatvideo-layer/.nvmrc, ../vue-chatvideo-layer/package.json, ../vue-chatvideo-layer/pnpm-lock.yaml, ../vue-chatvideo-layer/pnpm-workspace.yaml, ../vue-chatvideo-layer/tsconfig.json, ../vue-chatvideo-layer/nuxt.config.ts, ../vue-chatvideo-layer/README.md, ../vue-chatvideo-layer/.playground/nuxt.config.ts, ../vue-chatvideo-layer/.playground/app/app.vue, ../vue-chatvideo-layer/app/lib/videoClient.ts, ../vue-chatvideo-layer/app/lib/callMachine.ts, ../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts, ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue, ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue, ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue, ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue, ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css, ../vue-chatvideo-layer/i18n/locales/en.json, ../vue-chatvideo-layer/i18n/locales/pl.json, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/nuxt.config.ts, ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue, ../sm-demo-app/vue-demo-app/app/pages/chat.vue, ../sm-demo-app/vue-demo-app/app/layouts/default.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh, .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md</files>
<read_first>../sm-demo-app/plugins/golem15/chatvideo/README.md, api/video.go, classes/video_service.go (Start, Join, Leave, End, declined path), classes/notifier.go (event names), ../vue-chat-layer/app/lib/realtime.ts (onUserEvent, subscribe) and README.md (slots), the livekit-client README and API docs for the approved version (Room, RoomEvent, Track, connect, localParticipant.setCameraEnabled and setMicrophoneEnabled, track attach and detach; through Context7 or ctx7 when available), ../sm-demo-app/vue-demo-app/{app/pages/chat.vue,app/layouts/default.vue}, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/COVERAGE.md (matrix format), 20-RESEARCH.md (Pitfalls 8 and 9, assumption A1)</read_first>
<action>Per D-11, D-12, D-13, D-23.
1. Create ../vue-chatvideo-layer with the skeleton:
- dependencies `livekit-client@<approved>` installed with `--save-exact`, and @nuxtjs/i18n 10.4.0;
- peerDependencies nuxt 4.4.8 and `vue-chat-layer: "^0.1.0"`; devDependency vue-chat-layer at the git+https v0.1.0 tag for the playground;
- test script `vitest run` with vitest 3.2.7;
- runtime `public.chatvideo.apiBase: '/_chatvideo/api/v1'`;
- no `extends` of vue-chat-layer (the app extends both).
2. app/lib/callMachine.ts (pure): states idle, starting, connecting, connected, ended and failed, and `transition(state, event)` for the events start, joined(token, url), connected, participantJoined, participantLeft, mute, unmute, cameraOff, cameraOn, leave, ended(reason) and error(code). Mute and camera flags live in the state. Token errors (401, 403, 404, 422 too many participants) map to failed with a reason key.
3. app/lib/videoClient.ts: start, join, leave, end and status over `/conversations/{id}/video/...` with the `$chatAuth` token getter.
4. app/composables/useCall.ts:
- drives the machine; on start or join, `await import('livekit-client')`, creates a Room (adaptiveStream and dynacast on), connects with livekit_url and token, and enables camera and microphone;
- maps RoomEvent participant and track events to tiles and attaches and detaches tracks to video elements;
- offers mute and camera toggles through the local participant, and leave (disconnect, then POST leave);
- subscribes to the session channel through vue-chat-layer's `subscribe` for ended and declined events, with the channel name always built by an exported helper `videoSessionChannel(id)` in app/lib/videoClient.ts, and disconnects on route leave and pagehide.
useIncomingCalls registers `onUserEvent` for `video:call:incoming` (never its own user:{me} subscription) and exposes the pending call.
5. Components:
- ChatvideoCall (prop conversationId, start boolean): own camera tile, remote tiles (ChatvideoTile), mute, camera and leave buttons, the state text and failure messages from i18n;
- ChatvideoCallButton (props conversationId and callPath(id)): starts and navigates;
- ChatvideoIncomingCall: the caller name from the event payload, Accept (navigate to callPath) and Decline (POST leave; confirm in video_service.go that this publishes declined for a participant who never joined, else use the endpoint that does).
No screen share (D-13).
6. vue-demo-app:
- pnpm link dependency; extends gains 'vue-chatvideo-layer' after 'vue-chat-layer'; goPaths '/_chatvideo';
- app/pages/call/[conversation].vue: ClientOnly ChatvideoCall for members (start when the query has start=1), the register prompt otherwise;
- chat.vue `<template #conversation-actions="{ conversation }">` renders ChatvideoCallButton with callPath `id => '/call/' + id + '?start=1'` (the slot only renders in DMs, D-23);
- default.vue renders ClientOnly ChatvideoIncomingCall for members;
- EN/PL copy (call.title, call.registerToCall).
7. check-local.sh `check_call`:
- with the visitor JWT, POST /_chat/api/v1/contexts/demo/1/dm to a persona gives the DM id;
- POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with livekit_url and token;
- GET http://127.0.0.1:7890/rtc/validate?access_token=<token> answers 200 (LiveKit accepts the token, proving the keys and the room);
- POST .../video/end answers 2xx.
Add `call` to ALL_CHECKS.
8. tests/callMachine.test.ts (smoke): start, joined, connected, mute, leave and ended; a 422 start ends in failed with the too-many reason.
9. COVERAGE.md in the phase directory (format of 19.4.1's):
- livekit-client capabilities (Room connect and disconnect, publish camera and microphone, subscribe and attach remote tracks, mute and unmute, camera toggle, participant events, connection state and reconnection, adaptiveStream and dynacast INTEGRATE; screen share, data channel, device selection, E2EE, recording and egress, simulcast tuning, audio-only mode OPT-OUT, each with a reason);
- LiveKit server through chatvideo (room create and delete, token mint, list participants, reconcile INTEGRATE; egress, ingress, SIP and webhooks OPT-OUT);
- Centrifugo (the chat, user and video namespaces with presence on chat INTEGRATE; history, recovery and client publish OPT-OUT);
- Web Push through herald (VAPID keys and the public key route INTEGRATE; subscribe UI and push on chat events OPT-OUT, deferred per D-16).
Commit the layer, vue-demo-app (with the link pin), the scripts and COVERAGE.md (planning commit) separately.</action>
<verify>
<automated>pnpm --dir ../vue-chatvideo-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh call chat</automated>
<fails_when>Non-zero exit: the call machine tests fail, the build fails (livekit-client not resolvable or a type error), or check-local reports call failed (start refused, no token or livekit_url, LiveKit's rtc/validate not 200) or chat failed.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "import('livekit-client')" ../vue-chatvideo-layer/app/composables/useCall.ts` prints 1 and `grep -rn "^import .*from 'livekit-client'" ../vue-chatvideo-layer/app | grep -vc 'import type'` prints 0 (no static runtime import).
- `grep -rn 'subscribe(' ../vue-chatvideo-layer/app | grep -vc 'videoSessionChannel'` prints 0 (the layer's only subscriptions are session channels; incoming calls come through onUserEvent).
- `node -p "require('../vue-chatvideo-layer/package.json').dependencies['livekit-client']"` prints the approved version with no range character.
- `grep -c 'conversation-actions' ../sm-demo-app/vue-demo-app/app/pages/chat.vue` prints 1.
- COVERAGE.md has a reason on every OPT-OUT row (`grep -c 'OPT-OUT | *|' .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md` prints 0).
</acceptance_criteria>
<done>A member can start a video call from a DM, the other member gets an accept prompt on any page, and both meet on a minimal call page served by LiveKit.</done>
</task>
<task type="auto">
<name>Task 5: Herald's VAPID keys without UI, and the Forum, Friends and Calls tour cards</name>
<files>../sm-demo-app/scripts/dev.sh, ../sm-demo-app/deploy/env.example, ../sm-demo-app/README.md, ../sm-demo-app/vue-demo-app/app/pages/index.vue, ../sm-demo-app/vue-demo-app/app/components/TourCard.vue, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/scripts/check-local.sh</files>
<read_first>../sm-demo-app/plugins/golem15/herald/README.md (keys, herald:vapid-generate, presence gate warning), ../sm-demo-app/scripts/dev.sh (ensure_env, build, up), ../sm-demo-app/deploy/env.example, ../sm-demo-app/vue-demo-app/app/pages/index.vue, app/components/TourCard.vue, i18n/locales/*.json (tour.*), ../sm-demo-app/scripts/check-local.sh (check_tour)</read_first>
<action>Per D-16 and the tour part of D-03.
- dev.sh: after build in `up` and `seed`, when .env lacks SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY, run `./bin/demo herald:vapid-generate --json -q`, append SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY and __PRIVATE_KEY from the JSON (python3 parse, umask 077, values never echoed) and SUMMER_GOLEM15__HERALD__VAPID__SUBJECT=mailto:demo@example.test. Print only "added the herald VAPID keys to .env".
- deploy/env.example: the three herald key names with empty values and a comment (generated on rome with herald:vapid-generate; no subscribe UI in this phase, D-16). README "Chat family" section: herald is mounted without UI.
- Tour:
- index.vue stops in order journal, presentations, quizzes, chat, forum (/forum), friends (/friends), calls (/chat with text tour.calls.text), payments, faq, accounts;
- TourCard icons forum, friends and calls added to the union and ICONS (outline, 24-unit grid, currentColor);
- en and pl tour.forum, tour.friends and tour.calls titles and texts, and the heading for ten cards.
- check-local.sh check_tour expects ten cards including Forum, Friends and Calls (EN) and their Polish titles. New `check_herald`: GET $GO/_herald/api/v1/push/vapid-public-key answers 401 without a JWT and 200 with the visitor's JWT and a non-empty publicKey (json_has); add `herald` to ALL_CHECKS and the header list.
Commit sm-demo-app (dev.sh, env.example, README, check-local) and vue-demo-app (tour) separately.</action>
<verify>
<automated>pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/dev.sh up &amp;&amp; grep -q '^SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY=.' ../sm-demo-app/.env &amp;&amp; ../sm-demo-app/scripts/check-local.sh herald tour</automated>
<fails_when>Non-zero exit: the build fails, dev.sh does not write the VAPID public key into .env, or check-local reports herald failed (the public-key route is not 200 with a non-empty publicKey for the visitor, or not 401 without a JWT) or tour failed (card count or titles wrong).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'HERALD__VAPID__PRIVATE_KEY=' ../sm-demo-app/deploy/env.example` prints 1, with no value after the equals sign.
- `grep -c "'/_herald'" ../sm-demo-app/vue-demo-app/nuxt.config.ts` prints 0.
- `grep -cE "key: '(forum|friends|calls)'" ../sm-demo-app/vue-demo-app/app/pages/index.vue` prints 3.
- dev.sh output contains no line with a key value (manual look at the `up` output, recorded in the SUMMARY).
</acceptance_criteria>
<done>Herald runs with VAPID keys and no UI, and the tour shows all ten features in EN and PL.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 6: Go-ahead to create the forum, friends and video-call layer repositories, push them and tag v0.1.0</name>
<read_first>Claude's summary: `git log --oneline` of the three layers, their README install lines, vue-faq-layer's visibility, whether `tea login list` shows a git.golem15.com login, the livekit-client version approved in Task 3, and the results of check-local forum friends call tour chat.</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY; with "release", the three repositories exist on git.golem15.com (`git ls-remote` exits 0 for each) before Task 7 pushes, or Task 7 creates them with tea.
</acceptance_criteria>
<decision>Create vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer on git.golem15.com, push main and tag v0.1.0 each?</decision>
<context>Each layer is public with tagged releases that later sites pin (like D-01 for chat). v0.1.0 freezes their hooks, props and runtime keys. vue-chatvideo-layer's peer dependency on vue-chat-layer ^0.1.0 is part of its contract. vue-demo-app can pin them only after this. Repository creation and pushes need your go-ahead.</context>
<options>
<option id="release">
<name>Create, push and tag all three</name>
<pros>vue-demo-app pins the tags; the deploy (plan 05) can build.</pros>
<cons>The v0.1.0 contracts are public.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>Contracts stay open for changes.</pros>
<cons>The deploy is blocked; vue-demo-app keeps local link pins, so it cannot be pushed.</cons>
</option>
</options>
<instructions>For "release" without a tea login: create the three repositories (empty, default branch main, vue-faq-layer's visibility) in the git.golem15.com web UI, then reply. Do not paste tokens.</instructions>
<resume-signal>Reply "release" (add "repos ready" if you created them) or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 7: Release the three layers v0.1.0 and pin them in vue-demo-app</name>
<reversibility rating="one-way">Pushed layer tags are installed by consumers' lockfiles; they can be superseded but must never move.</reversibility>
<precondition>Task 6 resolved with "release".</precondition>
<files>../vue-forum-layer/README.md, ../vue-userfriends-layer/README.md, ../vue-chatvideo-layer/README.md, ../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml</files>
<read_first>The three READMEs, ../sm-demo-app/vue-demo-app/package.json, the Task 6 answer</read_first>
<action>For each of the three layers:
- create the repository with tea if missing, at vue-faq-layer's visibility;
- add the origin `git@git.golem15.com:golem15/<layer>.git` and push main;
- fix a README install line that says the tag does not exist before tagging;
- create the annotated tag v0.1.0, push it and check `ls-remote` against HEAD.
Then in vue-demo-app run `pnpm add` with the three `git+https://git.golem15.com/golem15/<layer>.git#v0.1.0` specs (replacing the link pins), run the tests and the build, and commit package.json and pnpm-lock.yaml (`feat: pin the forum, friends and video-call layers v0.1.0`). Do not push vue-demo-app.</action>
<verify>
<automated>for l in vue-forum-layer vue-userfriends-layer vue-chatvideo-layer; do [ "$(git -C ../$l ls-remote origin 'refs/tags/v0.1.0^{}' | cut -f1)" = "$(git -C ../$l rev-parse HEAD)" ] || exit 1; grep -q "$l.git#v0.1.0" ../sm-demo-app/vue-demo-app/package.json || exit 1; done &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; ../sm-demo-app/scripts/check-local.sh forum friends call chat tour</automated>
<fails_when>Non-zero exit: a remote tag is missing or differs from HEAD, a tag pin is missing from package.json, the vue-demo-app tests or build fail, or a check-local check fails against the tagged layers.</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-demo-app/vue-demo-app status --porcelain -- package.json pnpm-lock.yaml` prints nothing.
- `node -p "Object.values(require('../sm-demo-app/vue-demo-app/package.json').dependencies).filter(v=>v.startsWith('link')).length"` prints 0.
</acceptance_criteria>
<done>Forum, friends and video-call layers are published at v0.1.0 and the demo site builds against all tagged layers.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| forum posts and friend names to other members' browsers | untrusted content |
| browser to LiveKit (signal through nginx, media direct) | WebRTC with a short-lived join token |
| realtime events to the incoming-call prompt | payloads from the server about other users |
| npm registry to the chatvideo layer | a new third-party dependency (livekit-client) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-20 | Tampering | forum stored XSS | high | mitigate | posts render only content_html, which the server sanitizes on write with breakwater (D-18 of the forum port); Markdown is previewed through the server endpoint; acceptance grep limits v-html to content_html |
| T-20-21 | Spoofing | call joins | high | mitigate | tokens are minted by chatvideo only for conversation participants (sessionForActor) with token_ttl 900; the client never builds tokens; LiveKit room max_participants 4 (D-12) |
| T-20-22 | Information disclosure | camera and microphone | medium | mitigate | media starts only after an explicit start or accept on the call page; leave and pagehide disconnect and stop local tracks |
| T-20-23 | Tampering | incoming-call payloads | low | mitigate | caller names render as text bindings; Accept navigates only to the app's callPath built from the numeric conversation id |
| T-20-24 | Denial of service | call spam | medium | mitigate | server bucket video-initiate 5/min, max_duration 900 and the reconcile job (D-12); calls only offered in DMs (D-23) |
| T-20-25 | Information disclosure | friend enumeration | low | mitigate | the API's generic 422 for refused requests is shown as is; the directory lists demo members only (the same set the chat member picker shows) |
| T-20-26 | Information disclosure | VAPID private key | high | mitigate | generated into .env only (umask 077, never echoed); env.example holds names; /_herald is not routed by the site's goPaths |
| T-20-SC | Tampering | npm installs | high | mitigate | livekit-client installed only after the blocking human-verify legitimacy checkpoint (Task 3, gate blocking-human, never auto-approved) at an exact version with no install scripts; every other package is an existing workspace pin |
</threat_model>
<verification>
- Task 1: forum client tests, app build, check-local forum.
- Task 2: friends client tests, build, check-local friends forum chat.
- Task 3: livekit-client version approved by the user.
- Task 4: call machine tests, build, check-local call chat, COVERAGE.md complete.
- Task 5: build, VAPID keys in .env, herald route present, check-local tour.
- Task 6: go-ahead recorded.
- Task 7: three remote tags equal HEAD; vue-demo-app tests, build and checks green on tags.
</verification>
<success_criteria>
The demo site shows the forum, friends and DM video calls through three new shared layers (SC-2, D-11 to D-15, D-21, D-23), herald is configured without UI (D-16), and the tour lists every feature.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md` when done: the approved livekit-client version, the decline endpoint used, each layer's public API, the three tags and commits, and both checkpoint answers.
</output>

View File

@@ -0,0 +1,303 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 04
subsystem: vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer, vue-demo-app, sm-demo-app
tags: [forum, friends, video-calls, livekit, nuxt-layer, herald, vapid, tour, i18n]
requires:
- phase: 20-02
provides: "Chat family mounted in sm-demo-app, seeded forum and friends, local LiveKit, Centrifugo namespaces chat/user/video, GET /_demo/api/v1/chat/members"
- phase: 20-03
provides: "vue-chat-layer v0.1.0: conversation-actions (DM only) and sidebar-footer slots, onUserEvent and subscribe on the page's one realtime client, $chatAuth"
provides:
- "vue-forum-layer v0.1.0 on git.golem15.com (public), tag v0.1.0 = dcd6549"
- "vue-userfriends-layer v0.1.0 on git.golem15.com (public), tag v0.1.0 = bcc8e99"
- "vue-chatvideo-layer v0.1.0 on git.golem15.com (public), tag v0.1.0 = 75e9a40, livekit-client 2.23.0 exact"
- "vue-demo-app /forum, /forum/t/{id}, /friends, /call/{conversation}, the DM call button, the incoming-call prompt, ten tour cards, all three layers pinned by tag"
- "sm-demo-app check-local forum, friends, call, herald; tour expects ten cards; smoke.sh covers /forum and /friends; dev.sh writes herald's VAPID keys into .env"
- "Phase COVERAGE.md (livekit-client, LiveKit server, Centrifugo, Web Push)"
affects: [20-05, 20-06]
actuals:
tokens: 56900 # chars/4: the three layers' tracked files without lockfiles (56107 + 54654 + 63266) + vue-demo-app diff 3fe8018..9357711 without the lockfile (28917) + sm-demo-app diff b4fc7dc..fc23b4c in scripts, README and deploy (19375) + COVERAGE.md (5418) = 227737 chars
tasks: 7
commits: 1 # MEASURED in summercms.go: git rev-list --count b40c519..0301dc2 (COVERAGE.md) before this SUMMARY commit; code commits: vue-forum-layer 1, vue-userfriends-layer 1, vue-chatvideo-layer 1, vue-demo-app 5, sm-demo-app 4
plan_head_before: b40c519 # summercms.go
plan_head_after: 0301dc2ae48607c69847b3300ec844f80cad25d3 # summercms.go before this SUMMARY commit
tech-stack:
added:
- "livekit-client 2.23.0 (exact, vue-chatvideo-layer dependency; approved at the Task 3 checkpoint)"
- "vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer (new shared Nuxt layers); every other package is an existing workspace pin (@nuxtjs/i18n 10.4.0, nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, vitest 3.2.7)"
patterns:
- "Layer clients in app/utils carry a layer prefix on every export (forumError, friendTarget), because Nuxt auto-imports app/utils into the app"
- "A companion layer (vue-chatvideo-layer) takes vue-chat-layer as a peer dependency, does not extend it, and dedupes it in Vite so the realtime module stays one instance"
- "Token getter hooks per layer ($forumAuth, $userfriendsAuth), optional data hook ($userfriendsDirectory), $chatAuth reused by the companion layer"
key-files:
created:
- ../vue-forum-layer/app/utils/forumClient.ts
- ../vue-forum-layer/app/components/Forum/Thread.vue
- ../vue-forum-layer/README.md
- ../vue-userfriends-layer/app/utils/friendsClient.ts
- ../vue-userfriends-layer/app/components/Friends/Groups.vue
- ../vue-userfriends-layer/README.md
- ../vue-chatvideo-layer/app/lib/callMachine.ts
- ../vue-chatvideo-layer/app/lib/videoClient.ts
- ../vue-chatvideo-layer/app/composables/useCall.ts
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/README.md
- ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue
- ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue
- ../sm-demo-app/vue-demo-app/app/pages/friends.vue
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
- .planning/phases/20-demo-instance-with-every-shared-plugin/COVERAGE.md
modified:
- ../sm-demo-app/vue-demo-app/package.json
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/smoke.sh
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/README.md
key-decisions:
- "Task 3 checkpoint: the user approved livekit-client 2.23.0 (repository github.com/livekit/client-sdk-js, npm maintainer thedavidzhao, no install or postinstall hooks, npm provenance attestations, published 2026-10-16, 15 days old; compatibility with LiveKit server 1.13.1 not checked separately, the local 1.13.1 calls work)"
- "Task 6 checkpoint: release. The subagent's tea repo create was refused by the permission system; the orchestrator, after the user's go-ahead in manual mode, created the three repositories public, pushed main and the annotated tags v0.1.0 (remote tags equal HEAD)"
- "Decline posts video/leave: no chatvideo path publishes video:call:declined; the caller shows a did-not-answer note for a participant who left without joining"
- "The last one out ends the call (video/end instead of video/leave), so the next start rings again"
- "The call button's start answer is handed to the call page in memory (one start per call, no token in the URL)"
- "Forum manage rights come from the host: ForumThread canManage, filled by the demo from the chat bootstrap's moderator flag"
patterns-established:
- "check-local throwaway_user prints e-mail and JWT; LiveKit join tokens are validated with GET /rtc/validate, the token passed to curl through a config on stdin"
requirements-completed: [SC-2, D-11, D-12, D-13, D-14, D-15, D-16, D-20, D-21, D-22, D-23]
coverage:
- id: D1
description: "Members read the seeded EN/PL forum, create threads, reply, preview, edit and delete own posts; anonymous /forum has the register link to /register?next=/forum and no forum"
requirement: "D-14, D-21, D-22"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-local.sh forum"
status: pass
- kind: unit
ref: "../vue-forum-layer/tests/client.test.ts"
status: pass
- kind: manual
ref: "Playwright probe (scratchpad forum.cjs): categories, thread, preview, reply, edit, delete, new thread, no console error"
status: pass
human_judgment: true
- id: D2
description: "Friends, requests (accept, reject, cancel), unfriend, groups (create, invite code, join by code, leave); /friends linked from the chat sidebar, the header and /account/security"
requirement: "D-15"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-local.sh friends"
status: pass
- kind: unit
ref: "../vue-userfriends-layer/tests/client.test.ts"
status: pass
- kind: manual
ref: "Playwright probe (scratchpad friends.cjs) with two throwaway members"
status: pass
human_judgment: true
- id: D3
description: "DM-only call button; start, LiveKit token accepted, end; two members in a call with remote video, mute and camera off seen remotely, decline note, prompt cleared when the caller hangs up, server end ends the page's call"
requirement: "D-11, D-12, D-13, D-23"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-local.sh call"
status: pass
- kind: unit
ref: "../vue-chatvideo-layer/tests/callMachine.test.ts"
status: pass
- kind: manual
ref: "Playwright probe (scratchpad call.cjs) with fake media, against the link pins"
status: pass
human_judgment: true
- id: D4
description: "Herald VAPID keys in .env without printing them; public-key route 401 without and 200 with a JWT; no /_herald in goPaths"
requirement: "D-16"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-local.sh herald"
status: pass
human_judgment: false
- id: D5
description: "Ten tour cards in EN and PL; vue-demo-app tests and build against the three tags"
requirement: "D-03, D-20"
verification:
- kind: e2e
ref: "check-local.sh tour; forum friends call chat tour after the tag pin"
status: pass
- kind: build
ref: "pnpm --dir ../sm-demo-app/vue-demo-app test (209) && run build"
status: pass
human_judgment: false
duration: about 2 h of work (14:13 to 15:15 for Tasks 1 to 5, then the release checkpoint and Task 7 until 20:03)
completed: 2026-10-31
status: complete
---
# Phase 20 Plan 04: forum, friends and video-call layers v0.1.0 on the demo site Summary
**Three new shared layers are released at v0.1.0: vue-forum-layer (categories, threads, Markdown posts from the server's content_html), vue-userfriends-layer (friends, requests, groups with invite codes) and vue-chatvideo-layer (DM calls on livekit-client 2.23.0, loaded on demand). The demo site has /forum, /friends, /call/{conversation}, a call button in DM headers, an incoming-call prompt on every page, ten tour cards, and herald runs with VAPID keys and no UI.**
## Performance
- **Tasks:** 7/7. Task 3 (package legitimacy) and Task 6 (release go-ahead) were checkpoints answered by the user.
- **Files:** vue-forum-layer 22, vue-userfriends-layer 22 and vue-chatvideo-layer 22 tracked files; vue-demo-app 5 commits; sm-demo-app 4 commits.
## The layers' public API (v0.1.0)
**vue-forum-layer**
- Runtime config: `forum.apiBase` (`NUXT_FORUM_API_BASE`), `public.forum.apiBase` (`/_forum/api/v1`).
- Hook: `$forumAuth` (token getter).
- Components: `ForumPage` (contextType, contextId, threadPath), `ForumThread` (contextType, contextId, threadId, canManage, backPath), `ForumCategoryList`, `ForumThreadList`, `ForumPostEditor` (submit, preview, withTitle, …).
- Composables: `useForum`, `useForumApi`, `useForumAuth`. Client: `createForumClient`, `ForumApiError`, `forumContextPath`, `forumError`.
- Copy: `forum.*` EN and PL.
**vue-userfriends-layer**
- Runtime config: `userfriends.apiBase`, `public.userfriends.apiBase` (`/_userfriends/api/v1`).
- Hooks: `$userfriendsAuth` (token getter), optional `$userfriendsDirectory(): Promise<{id, name}[]>`.
- Components: `FriendsPage`, `FriendsList`, `FriendsRequests`, `FriendsAddFriend`, `FriendsGroups`.
- Composables: `useFriends` (useState keys `userfriends:*`), `useFriendsApi`, `useFriendsAuth`, `useFriendsDirectory`. Client: `createFriendsClient`, `FriendsApiError`, `friendTarget`, `friendsError`.
- Copy: `friends.*` EN and PL.
**vue-chatvideo-layer**
- Peer dependency `vue-chat-layer ^0.1.0` (not extended; the app extends both, chat first). Dependency `livekit-client` 2.23.0 exact.
- Runtime config: `public.chatvideo.apiBase` (`/_chatvideo/api/v1`). Auth: vue-chat-layer's `$chatAuth`.
- Components: `ChatvideoCallButton` (conversationId, callPath), `ChatvideoCall` (conversationId, start, backPath), `ChatvideoTile`, `ChatvideoIncomingCall` (callPath).
- Composables: `useCall`, `useVideoClient`, `useIncomingCalls` (`RING_MS` 45 s).
- Lib: `callMachine` (`initialCallState`, `transition`, `isLive`, `failureReason`), `videoClient` (`createVideoClient`, `VideoApiError`, `videoSessionChannel`, `videoPath`, `videoEvent`, `parseIncomingCall`, `rememberStart`, `takeStart`).
- Copy: `chatvideo.*` EN and PL, reason keys `chatvideo.reason.*`.
## Task Commits
| # | Repo | Commit | Message |
|---|------|--------|---------|
| 1 | vue-forum-layer | dcd6549 | feat: forum layer: categories, threads and Markdown posts |
| 1 | vue-demo-app | 2473517 | feat: /forum and /forum/t/{id} on vue-forum-layer |
| 1 | sm-demo-app | c4a9cfc | test: check-local forum, smoke covers /forum |
| 2 | vue-userfriends-layer | bcc8e99 | feat: friends layer: friends, requests and friend groups |
| 2 | vue-demo-app | 4edc81f | feat: /friends on vue-userfriends-layer, linked from the chat and the account |
| 2 | sm-demo-app | c2208e9 | test: check-local friends, smoke covers /friends |
| 3 | — | — | checkpoint: approved livekit-client 2.23.0 |
| 4 | vue-chatvideo-layer | 75e9a40 | feat: video-call layer: DM calls on LiveKit |
| 4 | vue-demo-app | 1db4394 | feat: DM video calls on vue-chatvideo-layer |
| 4 | sm-demo-app | c669717 | test: check-local call (DM start, LiveKit token validation, end) |
| 4 | summercms.go | 0301dc2 | docs(20-04): API coverage of livekit-client, LiveKit, Centrifugo and Web Push |
| 5 | sm-demo-app | fc23b4c | feat: herald VAPID keys without UI, check-local herald and the ten-card tour |
| 5 | vue-demo-app | cc8d330 | feat: Forum, Friends and Calls tour cards |
| 6 | — | — | checkpoint: release |
| 7 | vue-forum-layer, vue-userfriends-layer, vue-chatvideo-layer | tags v0.1.0 | created, pushed and tagged by the orchestrator (below) |
| 7 | vue-demo-app | 9357711 | feat: pin the forum, friends and video-call layers v0.1.0 |
**Release (Task 7).** The subagent's `tea repo create` was refused by the permission system ("Create Public Surface"), so nothing was created from the subagent. After the user's go-ahead (in manual permission mode), the orchestrator created `golem15/vue-forum-layer`, `golem15/vue-userfriends-layer` and `golem15/vue-chatvideo-layer` public (vue-faq-layer's visibility), pushed `main` and the annotated tags `v0.1.0`. `ls-remote 'refs/tags/v0.1.0^{}'` equals HEAD for each: dcd6549, bcc8e99, 75e9a40. The subagent then pinned the tags in vue-demo-app.
**Not pushed:** vue-demo-app and sm-demo-app (plan 05). sm-demo-app shows the moved `vue-demo-app` submodule pointer as uncommitted.
## Checkpoint answers
- **Task 3:** approved livekit-client 2.23.0. Facts checked by the orchestrator: repository github.com/livekit/client-sdk-js, npm maintainer thedavidzhao, no install or postinstall hooks, npm provenance attestations, published 2026-10-16 (15 days old). Compatibility with LiveKit server 1.13.1 was not checked from the release notes; calls on the local 1.13.1 work. Installed with `--save-exact`; lockfile integrity `sha512-j9Kg86nJ…`.
- **Task 6:** release.
## Verification
- **Layers:** forum `pnpm test` 7/7, friends 6/6, chatvideo vitest 10/10; `pnpm build:playground` builds for all three.
- **vue-demo-app:** `pnpm test` 209/209 and `pnpm run build` after every task and against the tags.
- **check-local.sh:** `forum` (Task 1), `friends forum chat` (Task 2), `call chat` (Task 4), `herald tour` (Task 5), `forum friends call chat tour herald` and `back remote realtime quizzes journal` (before the release), `forum friends call chat tour` against the tags (Task 7): all ok.
- **smoke.sh:** `smoke: ok`, with /forum and /friends in the page loop and their anonymous prompts.
- **Browser probes** (Playwright, throwaway members, fake media, against the link pins): the forum, friends and call flows listed in the coverage block passed with no console error. Against the tags, the call probe's ring step failed five times in a row because the stale servers took every incoming-call broadcast (environment notes); the tagged install itself resolves one vue-chat-layer copy (one entry in `node_modules/.pnpm`) and passes check-local call and chat.
- **Task 5 manual check:** `dev.sh up` printed only "added the herald VAPID keys to .env"; no line of the output holds a key value. A second `dev.sh seed` added nothing. The plan's `grep .env` was blocked by the secret-read hook; `check-local herald` (200 with a non-empty publicKey) shows the key is loaded.
- **Acceptance greps:** all print the expected values (no consuming-app names in the layers, v-html only on content_html, one `import('livekit-client')`, no static runtime import, every `subscribe(` line uses `videoSessionChannel`, exact livekit pin, one `conversation-actions`, one `sidebar-footer`, one `to="/friends"` in SiteHeader, no `'/_herald'`, three tour keys, `HERALD__VAPID__PRIVATE_KEY=` once and empty, no OPT-OUT row without a reason, no link pins left).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Decline has no declined event on the server**
- **Found during:** Task 4 (read video_service.go as the plan asked).
- **Issue:** `Notifier.Declined` exists, but no VideoService path calls it; `Leave` publishes `video:participant:left`. No endpoint publishes `video:call:declined`.
- **Fix:** Decline posts `video/leave`. The caller's page treats `video:participant:left` (and `video:call:declined`, should it ever come) for a participant who never connected as a decline and shows "did not answer". The shared plugin is unchanged (follow-up below).
- **Commit:** 75e9a40.
**2. [Rule 1 - Bug] A left-open session never rings again**
- **Found during:** Task 4.
- **Issue:** `start` answers the live session without a new `video:call:incoming` when one exists. If both members only posted `leave`, the session stayed open until the reconcile job, and the next call from the DM would not ring.
- **Fix:** Leave posts `video/end` when nobody else is in the room (the last one out ends the call), else `video/leave`. Both use `keepalive` so pagehide still sends them.
- **Commit:** 75e9a40.
**3. [Rule 2 - Missing critical] One start per call**
- **Issue:** The plan has both the button and the call page start the call; `video-initiate` allows 5 starts a minute.
- **Fix:** The button keeps its start answer in memory for 60 s (`rememberStart`), and the page takes it once (`takeStart`) before starting itself. The join token never goes into a URL.
- **Commit:** 75e9a40.
**4. Forum manage rights from the host**
- The forum API does not report manage rights. `ForumThread` takes `canManage`; the demo thread page fills it from `GET /_demo/api/v1/chat`'s `moderator` flag (the same group the forum policy checks). A refused action hides itself for the session.
- **Commit:** dcd6549, 2473517.
**5. [Rule 3 - Blocking] Auto-import collisions**
- Nuxt auto-imports `app/utils` into the app, so the layers' generic helper names would collide. Exports carry the layer's prefix: `forumError`, `forumContextPath`, `friendsError`, `friendTarget`.
- **Commit:** dcd6549, bcc8e99.
**6. [Rule 3 - Blocking] One vue-chat-layer copy**
- With the development `link:` pin, the linked checkout's own `node_modules/vue-chat-layer` (its dev dependency) would be a second realtime module that never hears the chat's events. vue-chatvideo-layer's nuxt.config sets `vite.resolve.dedupe: ['vue-chat-layer']`. With the link pin the incoming-call prompt works; with the tag install pnpm resolves the peer to the app's copy.
- **Commit:** 75e9a40.
**7. Smaller additions**
- check-local.sh: `throwaway_user` (e-mail and JWT) with `throwaway_jwt` on top of it; `check_call` reads Ada's id from `/chat/members`.
- smoke.sh: the anonymous prompts of /forum and /friends are checked, not only the page loop.
- `ensure_vapid` runs at the end of `seed()`, so `up` and `seed` both cover it.
- The add-friend picker hides current friends and people with a pending request.
- App copy lives under `forum.*`, `friends.*` and `call.*` next to the layers' trees (distinct keys), plus `header.friends`, `chat.friendsLink` and `security.friendsLink`.
- The plan's draft progress SUMMARY after Task 4 was skipped on the orchestrator's instruction; this SUMMARY is the only one.
**Total deviations:** 7. **Impact:** none on the plan goal; 1 and 2 make the call flow work with the plugin as it is.
## Follow-ups
- **sm-chatvideo-plugin:** `Notifier.Declined` is never called. A decline route (or `Leave` publishing `video:call:declined` for a participant still in status invited) would let the layer drop its participant-left inference. Not needed for this phase.
- **Herald:** the boot warning "PresenceGate is deny-all; the host must SetPresenceGate" remains; it matters only once push is sent (D-16 defers the subscribe UI).
## Environment notes
- **Stale demo servers share the local database.** `./bin/demo serve` pid 684528 (user jin, 127.0.0.1:8099, since Oct 22, in sm-demo-app) and pid 2918923 (user golem, 0.0.0.0:8096, since Oct 20) run River workers on `127.0.0.1:55540/summercms_demo` and take `summer.broadcast` jobs. Since the release, the current server ran 7 of about 50 broadcasts; the stale ones ran the rest, and their pushes do not reach the current Centrifugo clients. A Node test without a browser received 4 of 6 incoming-call pushes earlier; later the browser ring step failed 5 runs in a row. They were left running (not approved to stop). Stop them before plan 06's browser tests.
- **Headless Chrome** sometimes hits `ERR_NETWORK_CHANGED` (the host's network interfaces change, likely docker), which breaks page loads in probes.
- **Vite dev re-optimization:** the first `check-local chat` after adding a layer got a 504 "Outdated Optimize Dep" and passed on the rerun (dev server only).
- **Local secrets in a tool log:** while diagnosing, the Centrifugo `PROXY_SECRET`, `TOKEN_SECRET` and `API_KEY` from pid 684528's environment were printed into the session's tool output. They are local development values and were never committed; removing the `SUMMER_REALTIME__CENTRIFUGO__*` lines from `.env` and running `dev.sh up` rotates them.
## Known Stubs
None.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: supply-chain | ../vue-chatvideo-layer/package.json | New third-party dependency livekit-client 2.23.0 (exact), installed after the Task 3 checkpoint as planned in T-20-SC |
| threat_flag: supply-chain | ../sm-demo-app/vue-demo-app/package.json | Three new git dependencies pinned by tag v0.1.0 (commits dcd6549, bcc8e99, 75e9a40 in the lockfile) |
## Next Phase Readiness
- **Plan 05:** vue-demo-app pins every layer by tag and has no link pin, so it can be pushed. The sm-demo-app submodule pointer bump is still to do. The deploy needs `/_forum`, `/_userfriends` and `/_chatvideo` routed to Go in nginx (not `/_herald`), the herald VAPID keys in rome's `.env`, and LiveKit reachable at the production `livekit_url`.
- **Plan 06:** the call machine and the three clients have unit tests to extend; browser checks of the call flow need the stale servers stopped.
## Self-Check: PASSED
- Layer commits exist: vue-forum-layer dcd6549, vue-userfriends-layer bcc8e99, vue-chatvideo-layer 75e9a40; remote tags v0.1.0 peel to them.
- vue-demo-app: 2473517, 4edc81f, 1db4394, cc8d330, 9357711. sm-demo-app: c4a9cfc, c2208e9, c669717, fc23b4c. summercms.go: 0301dc2.
- The key files listed above exist in their repos.

View File

@@ -0,0 +1,359 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 05
type: execute
wave: 5
depends_on: ["20-02", "20-03", "20-04"]
files_modified:
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf
- ../sm-demo-app/deploy/livekit.yaml.example
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/scripts/check-live.sh
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/README.md
- ../sm-demo-app/vue-demo-app
autonomous: false
requirements: [SC-2, SC-3, D-10, D-12, D-16, D-20, D-21]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 6
confidence: low
must_haves:
truths:
- "D-10: rome runs the demo's own LiveKit 1.13.1 under supervisor as summercms-demo-livekit (user summercms-demo, config /srv/summercms-demo/livekit.yaml, 0640 root:summercms-demo, own key pair also in /srv/summercms-demo/.env), signal on 127.0.0.1:7890 behind nginx location /livekit/, ICE TCP 7891 and UDP mux 7892 on the public address; it does not touch WavePath's LiveKit (7880, 7881, 50000-60000) or its keys."
- "D-10: Vuurmuur on rome allows TCP 7891 and UDP 7892 from the internet to the firewall through a rule the user approved; DEPLOY.md documents the service, the rule, how to verify it and how to remove it."
- "D-12: the deployed LiveKit has room max_participants 4, empty_timeout 120 and departure_timeout 20; production chatvideo config keeps max_duration 900 and max_participants 4; the chatvideo reconcile job runs every 5 minutes inside serve."
- "D-16: rome's .env holds herald VAPID keys generated on rome with herald:vapid-generate; /_herald is not routed by nginx."
- "SC-3: after the release https://demo.summercms.io serves /chat, /forum, /friends and /call/{id} with the banner and the backend call to action; /backend and /backend/x answer 404; anonymous /chat and /forum show the register prompt (D-04, D-21); the admin stays at /smbackend as decided in 19.4.1."
- "SC-2: on the live demo the chat-family APIs answer 401 without a JWT under /_chat, /_chatchannels, /_chatbots, /_chatvideo, /_forum and /_userfriends, /livekit/rtc/validate reaches LiveKit, /ws upgrades to Centrifugo, and the Centrifugo config on rome has the chat, user and video namespaces."
- "scripts/check-deploy.sh proves locally that nginx, supervisor, livekit.yaml.example and the production chatvideo config agree on ports (7890, 7891, 7892) and caps, that each chat-family prefix goes to the Go port and that /_herald is not routed; scripts/check-live.sh https://demo.summercms.io prints check-live: ok after the release."
- "vue-demo-app's master is pushed before sm-demo-app's pointer bump commit; sm-demo-app is pushed with the go-ahead; robots.txt on the live site disallows the new API prefixes."
artifacts:
- path: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
provides: "supervisor program summercms-demo-livekit"
contains: "program:summercms-demo-livekit"
- path: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
provides: "Go locations for the chat family and the /livekit/ WebSocket location"
contains: "location ^~ /livekit/"
- path: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
provides: "production livekit_url and livekit_admin_url"
contains: "wss://demo.summercms.io/livekit"
- path: "../sm-demo-app/DEPLOY.md"
provides: "LiveKit, firewall, chat family release, verify and rollback"
contains: "summercms-demo-livekit"
key_links:
- from: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
to: "../sm-demo-app/deploy/livekit.yaml.example"
via: "proxy_pass http://127.0.0.1:7890/ equals LiveKit port 7890 (check-deploy compares)"
pattern: "127\\.0\\.0\\.1:7890"
- from: "../sm-demo-app/config/env/production/golem15.chatvideo.yaml"
to: "../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf"
via: "livekit_admin_url http://127.0.0.1:7890 is the program's signal port"
pattern: "livekit_admin_url"
- from: "../sm-demo-app/scripts/check-live.sh"
to: "../sm-demo-app/deploy/nginx/demo.summercms.io.conf"
via: "live 401s under the chat-family prefixes and LiveKit's answer on /livekit/rtc/validate"
pattern: "/livekit/rtc/validate"
prohibitions:
- statement: "No change to WavePath's LiveKit program, its config /etc/livekit/livekit.yaml or its ports, and no second key on its server."
status: unresolved
verification: judgment
- statement: "No secret value (LiveKit, VAPID, Centrifugo) appears in a committed file, the SUMMARY, a log line or a command line; values are generated on rome."
status: unresolved
verification: judgment
- statement: "No push, rome change or firewall change happens before its checkpoint is answered; raw iptables is never used (Vuurmuur manages rome's INPUT chain)."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): the demo is deployed publicly with the chat family, the frontend open, `/backend` unreachable and the backend call to action shown.
This plan's slice (plan 5 of 6, wave 5, per D-20): the rome deploy of everything plans 01 to 04 built. That means the demo's own LiveKit under supervisor, the Vuurmuur rule for its media ports (a checkpoint, shared production host), nginx locations, `.env` keys (LiveKit, VAPID), the Centrifugo namespaces, the release, DEPLOY.md, and the check-deploy and check-live extensions.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Media path (D-10 discretion):** ICE over the UDP mux port 7892 with ICE-TCP 7891 as fallback, and no embedded TURN. nginx `stream` with `ssl_preread` would restructure every 443 vhost on a shared host (RESEARCH Alternatives). If NAT'd browsers fail in plan 06's live check, the fallback recorded in DEPLOY.md is LiveKit's embedded TURN on UDP 3478 and TLS 5349 with a DNS-only host name, which needs a new checkpoint.
- **Signal path:** same origin under `/livekit` (`livekit_url: wss://demo.summercms.io/livekit`). livekit-client appends `/rtc`, so nginx `location ^~ /livekit/` proxies to `http://127.0.0.1:7890/` with the WebSocket upgrade headers, `proxy_read_timeout 7d` and `proxy_buffering off`, as lk.wavepath.org does. Go talks to the RoomService on loopback (`livekit_admin_url: http://127.0.0.1:7890`).
- **ICE reveals rome's public address** despite Cloudflare. This is accepted, because rome already exposes services on that address (RESEARCH Security Domain). DEPLOY.md says so.
- **Admin stays at /smbackend** (19.4.1's decision change of 2026-10-20, RESEARCH Open Question 1). `/backend` stays 404. The deploy checkpoint restates it.
- **Robots:** the new prefixes go into SEO › robots.txt on rome (the DEPLOY.md "SEO" procedure). `/_herald` stays unrouted and so needs no line.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- Supervisor program `summercms-demo-livekit` (`deploy/supervisor/summercms-demo-livekit.conf`); rome files `/srv/summercms-demo/livekit.yaml` and `/etc/supervisor/conf.d/summercms-demo-livekit.conf`.
- nginx locations `^~ /_chat/`, `^~ /_chatchannels/`, `^~ /_chatbots/`, `^~ /_chatvideo/`, `^~ /_forum/`, `^~ /_userfriends/` (Go, 127.0.0.1:8098) and `^~ /livekit/` (LiveKit, 127.0.0.1:7890).
- `config/env/production/golem15.chatvideo.yaml` (`livekit_url`, `livekit_admin_url`).
- env keys on rome: `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY`, `SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET`, `SUMMER_GOLEM15__HERALD__VAPID__PUBLIC_KEY`, `__PRIVATE_KEY`, `__SUBJECT` (names only in git).
- Vuurmuur service `summercms-demo-livekit` and its rule (rome).
- check-deploy.sh LiveKit and chat-family checks; check-live.sh chat-family checks; DEPLOY.md sections "LiveKit (the demo's own)", "Firewall (Vuurmuur)", "Chat family"; the sm-demo-app pointer bump of vue-demo-app.
<objective>
Deploy the chat family to the live demo on rome: LiveKit, firewall, nginx, keys, Centrifugo namespaces and the release, with the checks and the runbook to keep it repeatable.
Purpose: SC-3 (deployed publicly, /backend unreachable, call to action) and the live half of SC-2, D-10, D-12 and D-16 on rome.
Output: commits in ../sm-demo-app, pushes of vue-demo-app and sm-demo-app, and changes on rome, all after the go-ahead.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
Repo: ../sm-demo-app (deploy files, scripts, DEPLOY.md), its submodule vue-demo-app (push only). Host: rome over `ssh -p 2222 debian@10.8.3.118` (passwordless sudo; the `rome` alias does not resolve here), with the variables `$RSH` and `$ROME` used in DEPLOY.md. Read-only on rome: WavePath's `/etc/livekit/livekit.yaml` and `wavepath-livekit` program, `/etc/vuurmuur/services`, `/etc/vuurmuur/rules/rules.conf`.
Rules: no co-author tags; stage by explicit path; push vue-demo-app first, then commit and push the pointer bump in sm-demo-app (core-plugins-own-repos note); secrets are generated on rome with `openssl rand` or `herald:vapid-generate` and written by remote commands that never echo values; follow DEPLOY.md's Build, Upload and Release sections; nothing changes on rome and nothing is pushed before Task 3's answer; the firewall changes only after Task 5's answer.
<interfaces>
sm-demo-app deploy today: deploy/nginx/demo.summercms.io.conf (one `location ^~ /_<prefix>/` per Go prefix with proxy_pass http://127.0.0.1:8098, `location = /ws` to Centrifugo 8010 with Upgrade headers, `location ^~ /backend { return 404; }`, `/smbackend` to Go), deploy/supervisor/summercms-demo-centrifugo.conf (program shape), deploy/centrifugo.json.example (namespaces from plan 02), deploy/livekit.yaml.example (plan 02), scripts/check-deploy.sh (nginx -t in a temp prefix, supervisor parse, port agreement, prints "check-deploy: ok"), scripts/check-live.sh (bad, get, header, ws_upgrade helpers; prints "check-live: ok"), DEPLOY.md (Overview, Launch checklist, One-time server setup, Realtime, MCP endpoint, SEO, Build, Upload, Release, Verify, Rollback, Reset, Secrets).
RESEARCH facts: rome LiveKit 1.13.1 at /usr/local/bin/livekit-server; WavePath's program uses 7880, 7881/tcp and 50000-60000/udp; ports 7890-7892 were free on 2026-10-28 (re-check with `ss -ltnup`); rome's INPUT policy is DROP under Vuurmuur (vrmr NFLOG prefixes); a Vuurmuur service Livekit exists but no rule uses it.
LiveKit validate endpoint: GET /rtc/validate answers 401 without a token and 200 with a valid one.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the deploy files carry one chat-family release end to end, proven locally by check-deploy before rome is touched</name>
<precondition>The plan 04 SUMMARY records the three layer tags pinned in vue-demo-app; `git -C ../sm-demo-app status --porcelain` and `git -C ../sm-demo-app/vue-demo-app status --porcelain` list no foreign edit in the files of this task.</precondition>
<files>../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf, ../sm-demo-app/deploy/livekit.yaml.example, ../sm-demo-app/deploy/env.example, ../sm-demo-app/config/env/production/golem15.chatvideo.yaml, ../sm-demo-app/scripts/check-deploy.sh, ../sm-demo-app/scripts/check-live.sh, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/README.md</files>
<read_first>../sm-demo-app/deploy/nginx/demo.summercms.io.conf (whole file), deploy/supervisor/summercms-demo-centrifugo.conf, deploy/livekit.yaml.example, deploy/env.example, config/env/production/*.yaml, scripts/check-deploy.sh (whole file), scripts/check-live.sh (whole file), DEPLOY.md (whole file), 20-RESEARCH.md (Pitfalls 6, 7, 8, 11; Supervisor program and LiveKit config blocks)</read_first>
<action>Per D-10, D-12, D-16, SC-2, SC-3.
- nginx: add `location ^~ /_chat/`, `/_chatchannels/`, `/_chatbots/`, `/_chatvideo/`, `/_forum/` and `/_userfriends/` with the exact /_demo/ block, and `client_max_body_size 2m` on /_chat/ for attachments (chat's 1 MiB upload limit plus multipart overhead). Add `location ^~ /livekit/` with proxy_pass `http://127.0.0.1:7890/`, `proxy_http_version 1.1`, the Upgrade and Connection headers, Host and X-Forwarded headers, `proxy_read_timeout 7d`, `proxy_send_timeout 7d` and `proxy_buffering off`, and a comment that media bypasses nginx. Update the `/api/realtime/token` comment: every demo member now gets a token (demo.realtime-access). Add nothing for /_herald.
- deploy/supervisor/summercms-demo-livekit.conf: RESEARCH's program block, with a header comment in the Centrifugo file's style (ports, config path and mode, binary, that WavePath's instance is separate).
- deploy/livekit.yaml.example: confirm or adjust the plan 02 file so production uses `use_external_ip: true`, and comment the rome install command.
- config/env/production/golem15.chatvideo.yaml: `livekit_url: "wss://demo.summercms.io/livekit"`, `livekit_admin_url: "http://127.0.0.1:7890"`.
- deploy/env.example: the herald subject key with its comment, if plan 04 left it out.
- check-deploy.sh, new checks with header lines:
- summercms-demo-livekit.conf parses and declares its program with the user summercms-demo;
- LiveKit ports agree: the example's `port` equals the nginx /livekit/ proxy_pass port and the production livekit_admin_url port; `rtc.tcp_port` 7891 and `rtc.udp_port` 7892 are set, no port_range key is set, and bind_addresses is 127.0.0.1;
- room.max_participants equals config/golem15.chatvideo.yaml max_participants (D-12, D-23);
- each of the six chat-family prefixes is a location proxied to the Go port;
- no location routes /_herald;
- the example holds placeholder keys only.
Parse YAML with python3 as the script already does for JSON, and fail with one line per mismatch.
- check-live.sh, new checks with header lines:
- /chat, /forum and /friends answer 200 with the banner and a register link (D-04, D-21);
- GET /_chatchannels/api/v1/contexts/demo/1/channels, /_forum/api/v1/contexts/demo/1/categories, /_userfriends/api/v1/friends, /_chatbots/api/v1/commands and /_chatvideo/api/v1/conversations/1/video/status answer 401;
- GET /livekit/rtc/validate answers 401 (or 400) from LiveKit, not Nuxt's HTML 404 (the body is not text/html);
- GET /_herald/api/v1/push/vapid-public-key does not answer JSON from Go;
- robots.txt holds `Disallow: /_chat` and `Disallow: /_forum`.
- DEPLOY.md:
- Overview: ports 7890-7892, the new program;
- One-time server setup "LiveKit (the demo's own)": install the config with generated keys, the supervisor program, verify with curl on 127.0.0.1:7890;
- "Firewall (Vuurmuur)": the service definition (TCP 7891, UDP 7892), the rule from any to the firewall, `vuurmuur_script` apply and reload, verification with `ss -lunp` and `iptables -S | grep 789`, and removal;
- "Chat family": the .env keys, the Centrifugo namespaces and when to restart Centrifugo, the VAPID keys, the robots.txt lines to add;
- Verify: the new check-live items;
- Rollback: stopping summercms-demo-livekit turns calls off only;
- Reset: the chat family is included.
README links to the new sections.
Commit `feat: deploy files for the chat family and the demo's LiveKit`.</action>
<verify>
<automated>../sm-demo-app/scripts/check-deploy.sh 2&gt;&amp;1 | tail -1 | grep -qx 'check-deploy: ok' &amp;&amp; bash -n ../sm-demo-app/scripts/check-live.sh</automated>
<fails_when>The last line is not "check-deploy: ok" (nginx -t fails with the new locations, a port or cap mismatch, a missing prefix, a routed /_herald, or a supervisor parse error), or check-live.sh has a syntax error.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'location ^~ /livekit/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 1.
- `grep -cE 'location \^~ /_(chat|chatchannels|chatbots|chatvideo|forum|userfriends)/' ../sm-demo-app/deploy/nginx/demo.summercms.io.conf` prints 6.
- `grep -c '\[program:summercms-demo-livekit\]' ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf` prints 1.
- `grep -c 'Vuurmuur' ../sm-demo-app/DEPLOY.md` prints at least 2.
</acceptance_criteria>
<done>The repository holds every file the rome release needs, and the local check proves they agree.</done>
</task>
<task type="auto">
<name>Task 2: Read rome's current state and prepare the release build without changing rome</name>
<files>../sm-demo-app/DEPLOY.md</files>
<read_first>../sm-demo-app/DEPLOY.md (Build, Upload, Release, Secrets), ../sm-demo-app/scripts/build.sh, .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-02-SUMMARY.md (first deploy notes)</read_first>
<action>Per D-10 and SC-3. Read-only on rome:
- `ss -ltnup` filtered on 7890, 7891 and 7892 (free?);
- `supervisorctl status`;
- `grep -c` for each new key name in /srv/summercms-demo/.env (counts only, never values);
- whether /srv/summercms-demo/centrifugo.json lists the chat namespace (`grep -c '"chat"'`);
- `ls /etc/vuurmuur/services` and `grep -n Livekit /etc/vuurmuur/rules/rules.conf` (no edit);
- `/usr/local/bin/livekit-server --version`.
Locally:
- `scripts/check-pins.sh`, `scripts/build.sh release` (refuses a dirty tree or off-tag submodules) and `scripts/smoke.sh` against the release build as DEPLOY.md's Build section says;
- `git -C vue-demo-app status` (the commits from plans 03 and 04 are local only) and `git log origin/master..master` in both repositories.
Record everything in a deploy table for the Task 3 question. If a port is taken, pick the next free trio and update the deploy files and check-deploy in a commit before asking. If DEPLOY.md's Build steps no longer match the scripts, fix them in DEPLOY.md.</action>
<verify>
<automated>../sm-demo-app/scripts/check-pins.sh &amp;&amp; ../sm-demo-app/scripts/build.sh release &amp;&amp; ../sm-demo-app/scripts/smoke.sh</automated>
<fails_when>Non-zero exit: a submodule off its go.mod tag, the release build refuses (dirty tree, stale ROADMAP snapshot) or fails to compile without go.work, or smoke.sh reports a missing banner, an admin path or a credential on a rendered page.</fails_when>
</verify>
<acceptance_criteria>
- The SUMMARY's deploy table lists rome's listeners on 7890-7892 (or the chosen trio), the supervisor programs, which env keys exist (counts only), whether centrifugo.json has the chat namespace, the LiveKit version and the unpushed commits per repository.
- No file on rome changed (no sudo write command was run).
</acceptance_criteria>
<done>The release build is ready and the go-ahead question rests on rome's actual state.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 3: Go-ahead to push the demo and deploy the chat family to rome</name>
<read_first>The Task 2 deploy table; ../sm-demo-app/DEPLOY.md sections "LiveKit (the demo's own)", "Chat family" and "Release"</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY.
</acceptance_criteria>
<decision>Push vue-demo-app and sm-demo-app and release the chat family on rome now (LiveKit program, keys, nginx, Centrifugo namespaces, binary and site)?</decision>
<context>The release changes a shared production host: a new supervisor program (summercms-demo-livekit on 127.0.0.1:7890), new .env keys generated on rome (LiveKit and VAPID), an nginx reload for the demo vhost, a Centrifugo restart for the demo's own instance, a migration of the chat-family tables and demo:seed. WavePath's LiveKit and keys are not touched. The admin stays at /smbackend with login (19.4.1's decision) and /backend stays 404. Media ports stay closed until the separate firewall question (Task 5), so calls connect only after that. Rollback is in DEPLOY.md (previous binary and site; stop the LiveKit program).</context>
<options>
<option id="deploy">
<name>Push and deploy</name>
<pros>Chat, forum and friends go live; calls follow after the firewall rule.</pros>
<cons>Changes rome; the new tables are migrated (not reversed on rollback).</cons>
</option>
<option id="push-only">
<name>Push the repositories, deploy later</name>
<pros>Code is on origin and reviewed; rome is unchanged.</pros>
<cons>SC-3 stays open; plan 06's live verification waits.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>Nothing leaves this machine.</pros>
<cons>The phase cannot verify live.</cons>
</option>
</options>
<resume-signal>Reply "deploy", "push-only" or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 4: Release: push, install LiveKit and keys on rome, roll out the binary and site, and check the live demo</name>
<precondition>Task 3 answered "deploy" (or "push-only", which runs steps 1 and 2 only).</precondition>
<files>../sm-demo-app/vue-demo-app, ../sm-demo-app/DEPLOY.md</files>
<read_first>../sm-demo-app/DEPLOY.md (whole runbook), the Task 2 deploy table</read_first>
<action>Per D-10, D-12, D-16, SC-2, SC-3.
1. Push vue-demo-app master, then in sm-demo-app commit the pointer bump `chore: bump vue-demo-app (chat, forum, friends, calls)` and push sm-demo-app.
2. Confirm both remotes equal local HEAD (`git ls-remote`).
3. On rome, following DEPLOY.md. Never echo or log a value; write secrets with remote heredocs fed from `openssl rand` inside the remote shell.
- Generate a LiveKit key and secret.
- Install /srv/summercms-demo/livekit.yaml (0640 root:summercms-demo) from the example with them, and append the same values to .env as the SUMMER_GOLEM15__CHATVIDEO__ keys.
- Run `herald:vapid-generate --json -q` with the new binary on rome and append the VAPID keys. Set the subject to the demo's contact address from ~/.config/summercms/demo-secrets.env, sent over SSH stdin.
- Update /srv/summercms-demo/centrifugo.json: merge the chat, user and video namespaces from the example while keeping its secrets, run `centrifugo checkconfig`, and restart summercms-demo-centrifugo.
- Install and start summercms-demo-livekit (reread, update, status RUNNING) and check that `curl -s http://127.0.0.1:7890/` answers OK on rome.
- Upload and release the build per DEPLOY.md (migrate, demo:seed, swap, restart), then install the nginx file and reload.
- Add the robots.txt lines for the six prefixes through SEO › robots.txt (DEPLOY.md "SEO"; through an SSH tunnel to the admin with the credentials in /root/summercms-demo-admin.txt, or the seo MCP tools with the administrator's connector). If neither path works unattended, list it for the user in Task 6.
4. Run `scripts/check-live.sh https://demo.summercms.io`. The /livekit/rtc/validate check passes without the firewall rule, because signalling goes through nginx.
5. Record in DEPLOY.md any step that differed from the runbook.</action>
<verify>
<automated>[ "$(git -C ../sm-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app rev-parse HEAD)" ] &amp;&amp; ../sm-demo-app/scripts/check-live.sh https://demo.summercms.io 2&gt;&amp;1 | tail -1 | grep -qx 'check-live: ok'</automated>
<fails_when>sm-demo-app's remote master differs from HEAD (not pushed), or check-live's last line is not "check-live: ok" (a chat-family prefix not 401, /livekit not reaching LiveKit, /backend not 404, missing banner or call to action, robots lines missing, or /ws not 101).</fails_when>
</verify>
<acceptance_criteria>
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status summercms-demo-livekit` shows RUNNING.
- `ssh -p 2222 debian@10.8.3.118 'sudo grep -c "^SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_SECRET=." /srv/summercms-demo/.env'` prints 1 (count only).
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status wavepath-livekit` shows the same uptime it showed in Task 2 (not restarted).
- The SUMMARY holds no secret value.
</acceptance_criteria>
<done>The chat family is live on demo.summercms.io: chat, forum, friends and the call signalling path answer, with WavePath's services untouched.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 5: Go-ahead for the Vuurmuur rule that opens LiveKit's media ports on rome</name>
<read_first>DEPLOY.md "Firewall (Vuurmuur)"; the read-only output Claude prints: the proposed service file content (TCP 7891, UDP 7892), the proposed rule line, the current rules.conf lines around the Livekit service, and `iptables -S | grep -c vrmr` before the change</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY.
</acceptance_criteria>
<decision>Open TCP 7891 and UDP 7892 on rome for the demo's LiveKit through Vuurmuur?</decision>
<context>rome's INPUT chain is managed by Vuurmuur with policy DROP; raw iptables rules would be overwritten. Without this rule calls signal but never connect media. The rule only admits traffic to the demo's LiveKit ports (the program binds media on the public address; signalling stays on loopback behind nginx). It is reversible: removing the rule and reloading Vuurmuur closes the ports. WavePath's own Livekit service definition stays unused and unchanged.</context>
<options>
<option id="claude-applies">
<name>Claude applies the rule</name>
<pros>Done now, with the verification commands from DEPLOY.md.</pros>
<cons>A change to the shared host's firewall made by the agent.</cons>
</option>
<option id="user-applies">
<name>I apply it myself</name>
<pros>You make the firewall change on the shared host.</pros>
<cons>Calls stay disconnected until you reply that it is done.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>The firewall stays as it is.</pros>
<cons>Video calls do not connect on the live demo (D-10 open); plan 06's call check is skipped.</cons>
</option>
</options>
<instructions>For "user-applies": follow DEPLOY.md "Firewall (Vuurmuur)" and reply "firewall done".</instructions>
<resume-signal>Reply "claude-applies", "firewall done" or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 6: Apply and verify the media ports, and hand the live demo to plan 06</name>
<precondition>Task 5 answered "claude-applies" or "firewall done".</precondition>
<files>../sm-demo-app/DEPLOY.md</files>
<read_first>DEPLOY.md "Firewall (Vuurmuur)", the Task 5 answer</read_first>
<action>Per D-10.
- For "claude-applies": on rome, add the service file /etc/vuurmuur/services/summercms-demo-livekit (TCP 7891, UDP 7892) and the rule line from DEPLOY.md, apply with Vuurmuur's own tooling (`vuurmuur_script` apply and reload, never raw iptables), and back up rules.conf first to rules.conf.bak-<date>.
- Either way, verify:
- `sudo iptables -S | grep -E '7891|7892'` shows ACCEPT rules with the vrmr prefix;
- `ss -lunp | grep 7892` shows livekit-server;
- from this machine, `nc -zv -w 3 <rome public address> 7891` connects (UDP is verified by the live call in plan 06).
- Note in DEPLOY.md the date applied and the backup path.
- If robots.txt could not be updated unattended in Task 4, list the exact lines for the user in the SUMMARY.
Commit any DEPLOY.md change (`docs: record the LiveKit firewall rule`), then push sm-demo-app.</action>
<verify>
<automated>ssh -p 2222 debian@10.8.3.118 "sudo iptables -S | grep -cE 'dport (7891|7892)'" | grep -qvx 0 &amp;&amp; nc -zv -w 3 54.36.173.176 7891</automated>
<fails_when>The iptables count is 0 (Vuurmuur did not apply the rule), or the TCP connect to 7891 on the public address times out or is refused.</fails_when>
</verify>
<acceptance_criteria>
- `ssh -p 2222 debian@10.8.3.118 test -f /etc/vuurmuur/services/summercms-demo-livekit` exits 0.
- `../sm-demo-app/scripts/check-live.sh https://demo.summercms.io` still prints `check-live: ok`.
- The SUMMARY states whether robots.txt was updated and lists any item left for the user.
</acceptance_criteria>
<done>LiveKit's media ports are reachable from the internet through Vuurmuur and the live demo passes its checks.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| internet to nginx (Cloudflare) on demo.summercms.io | untrusted HTTP and WebSocket, now including /livekit/ and six API prefixes |
| internet to rome's public address on 7891/tcp and 7892/udp | WebRTC media straight to LiveKit, bypassing Cloudflare |
| build machine to rome over SSH | secrets generated on rome, never transferred |
| demo services to WavePath services on the same host | must stay isolated |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-27 | Elevation of privilege | LiveKit co-tenancy with WavePath | high | mitigate | own instance, own key pair, own ports and config file (D-10); no key added to /etc/livekit/livekit.yaml; Task 4 asserts wavepath-livekit was not restarted |
| T-20-28 | Information disclosure | LiveKit, VAPID and Centrifugo secrets | high | mitigate | generated on rome inside the remote shell, written to 0640 root:summercms-demo files, never echoed; repository files hold names and placeholders; check-deploy fails on non-placeholder keys in the example |
| T-20-29 | Denial of service | open media ports | medium | mitigate | only TCP 7891 and UDP 7892 via a Vuurmuur rule the user approved; room max_participants 4, empty_timeout 120, departure_timeout 20, max_duration 900 with reconcile (D-12) |
| T-20-30 | Information disclosure | origin IP through ICE candidates | low | accept | rome already exposes services on its public address; recorded in DEPLOY.md |
| T-20-31 | Elevation of privilege | unintended Go routes | medium | mitigate | nginx allow-list gains exactly six prefixes and /livekit/; /_herald stays unrouted; check-deploy asserts both; /backend 404 unchanged |
| T-20-32 | Tampering | firewall drift | medium | mitigate | changes only through Vuurmuur with a dated backup of rules.conf; DEPLOY.md documents removal |
| T-20-33 | Denial of service | release breaking the live demo | medium | mitigate | release build and smoke before the go-ahead (Task 2); DEPLOY.md rollback keeps bin/demo.prev and site/.output.prev; check-live after release |
| T-20-34 | Spoofing | signalling WebSocket | low | mitigate | LiveKit validates every join token (HS256, token_ttl 900) minted only for conversation participants by chatvideo |
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: the release installs vue-demo-app's frozen lockfile (layers pinned by tag, livekit-client approved in plan 04) and Go modules by tag with go.sum |
</threat_model>
<verification>
- Task 1: check-deploy ok with the new locations, program and port agreement.
- Task 2: check-pins, release build and smoke; rome read-only table.
- Task 3: go-ahead recorded.
- Task 4: remotes pushed; check-live ok on https://demo.summercms.io; LiveKit running; WavePath untouched.
- Task 5: firewall decision recorded.
- Task 6: iptables shows the Vuurmuur rule; TCP 7891 reachable; check-live still ok.
</verification>
<success_criteria>
The chat family runs on the public demo with its own LiveKit and open media ports, /backend refused and the call to action shown (SC-3), with a runbook and checks that make the next release routine.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md` when done: the deploy table (no secret values), ports used, the commits pushed, what changed on rome, both checkpoint answers, the robots.txt state, and anything left for the user.
</output>

View File

@@ -0,0 +1,162 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 05
subsystem: sm-demo-app deploy, rome
tags: [deploy, rome, livekit, vuurmuur, nginx, centrifugo, herald, vapid, robots, check-live]
requires:
- phase: 20-02
provides: "chat family mounted in sm-demo-app, deploy/livekit.yaml.example, Centrifugo namespaces in the example, production livekit_url"
- phase: 20-03
provides: "vue-chat-layer v0.1.0 pinned in vue-demo-app"
- phase: 20-04
provides: "forum, friends and video-call layers v0.1.0 pinned in vue-demo-app, herald VAPID handling in dev.sh"
provides:
- "demo.summercms.io serves /chat, /forum, /friends and /call/{id} with the banner and register prompts; the chat-family APIs answer 401 without a JWT"
- "The demo's own LiveKit 1.13.1 on rome (supervisor program summercms-demo-livekit, 127.0.0.1:7890 behind nginx /livekit/, media 7891/tcp and 7892/udp open through Vuurmuur)"
- "Centrifugo namespaces chat, user, video on rome; herald VAPID keys in rome's .env; robots.txt disallows the new prefixes"
- "scripts/check-deploy.sh LiveKit and chat-family checks; scripts/check-live.sh chat-family checks; DEPLOY.md runbooks for LiveKit, the firewall and the chat family"
affects: [20-06]
actuals:
tokens: 14800 # chars/4 over sm-demo-app fc23b4c..f2e35c1 without the ROADMAP snapshot and the gitlink (59224 chars)
tasks: 6
commits: 0 # MEASURED in summercms.go: git rev-list --count 6389479..HEAD before this SUMMARY commit (docs-only plan here); sm-demo-app: 6 (git rev-list --count fc23b4c..f2e35c1)
plan_head_before: 63894799065795c54d91e40094692cf4f0cbe419 # summercms.go
plan_head_after: 63894799065795c54d91e40094692cf4f0cbe419 # summercms.go before this SUMMARY commit; sm-demo-app fc23b4c -> f2e35c1, vue-demo-app 44a5229 -> 9357711 (pushed)
tech-stack:
added: []
patterns:
- "Secrets generated on rome go into files through bash builtins and stdin only (printf, tee), never as a process argument"
- "Shared-host supervisor changes name the program (supervisorctl update <name>), so no other program restarts"
- "Admin-only settings changed unattended through rome's loopback admin API, the password read on rome into a 0700 temp dir"
key-files:
created:
- ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf
modified:
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/deploy/livekit.yaml.example
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/scripts/check-live.sh
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/README.md
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
- ../sm-demo-app/vue-demo-app
key-decisions:
- "Task 3 checkpoint: deploy (push both repositories and release the chat family on rome)"
- "Task 5 checkpoint: firewall done; the user applied the Vuurmuur rule as service sm-demo-livekit (7891/tcp, 7892/udp), rule on line 1 of rules.conf"
- "Media path: ICE over the UDP mux 7892 with ICE-TCP 7891, no embedded TURN; TURN on 3478/5349 with a DNS-only host is the recorded fallback"
- "Admin stays at /smbackend with login; /backend answers 404"
- "robots.txt updated through PUT /_seo/api/v1/robots on rome's loopback (admin bearer token), not by hand"
- "VAPID subject mailto:DEMO_ADMIN_EMAIL from the build machine's secrets file"
requirements-completed: [SC-2, SC-3, D-10, D-12, D-16, D-20, D-21]
duration: about 5 h of work over two sessions (2026-10-31 evening to 2026-11-01 18:40), with three checkpoints and the user's own firewall change
completed: 2026-11-01
status: complete
---
# Phase 20 Plan 05: chat family deployed on demo.summercms.io with its own LiveKit Summary
**The chat family runs on the public demo: https://demo.summercms.io serves /chat, /forum, /friends and /call/{id} with the banner and register prompts, every chat-family API answers 401 without a JWT, /livekit/rtc/validate reaches the demo's own LiveKit 1.13.1 (supervisor program summercms-demo-livekit, separate from WavePath's), Centrifugo has the chat, user and video namespaces, herald has VAPID keys without a route, and Vuurmuur admits LiveKit's media on 7891/tcp and 7892/udp. `check-live.sh https://demo.summercms.io` prints `check-live: ok`.**
## Task Commits
| # | Repo | Commit | Message |
|---|------|--------|---------|
| 1 | sm-demo-app | b5c639e | feat: deploy files for the chat family and the demo's LiveKit |
| 2 | sm-demo-app | cf08fb5 | fix: check-live tells an unrouted /_herald by its 404, not its type |
| 3 | — | — | checkpoint: deploy |
| 4 | vue-demo-app | (push) | 44a5229..9357711 pushed to origin master |
| 4 | sm-demo-app | c399bfd | chore: bump vue-demo-app (chat, forum, friends, calls) |
| 4 | sm-demo-app | 6d726b4 | chore: sync the roadmap snapshot |
| 4 | sm-demo-app | fcd8d95 | docs: record the chat-family release on rome |
| 5 | — | — | checkpoint: firewall done (applied by the user) |
| 6 | sm-demo-app | f2e35c1 | docs: record the LiveKit firewall rule |
**Pushed:** vue-demo-app master = 9357711 (`ls-remote` equals local HEAD, and sm-demo-app's gitlink points to it); sm-demo-app master = f2e35c1 (`ls-remote` equals local HEAD). Push order: vue-demo-app first, then the pointer bump.
## Deploy table (Task 2, rome read-only, 2026-10-31/11-01; no secret values)
| Item | State before the release |
|------|--------------------------|
| Listeners on 7890, 7891, 7892 | none (free); the trio was kept |
| WavePath LiveKit | `wavepath-livekit` RUNNING pid 1104; 127.0.0.1:7880, *:7881, UDP 50000-60000 |
| LiveKit binary | `/usr/local/bin/livekit-server` 1.13.1 |
| Demo programs | summercms-demo, summercms-demo-site, summercms-demo-centrifugo RUNNING; no summercms-demo-livekit, conf or livekit.yaml |
| `.env` key counts | CHATVIDEO__LIVEKIT_API_KEY 0, __LIVEKIT_API_SECRET 0, HERALD__VAPID__PUBLIC_KEY 0, __PRIVATE_KEY 0, __SUBJECT 0 |
| centrifugo.json | namespaces `["presentations"]` only (chat 0, user 0, video 0); Centrifugo v6.5.1 |
| Vuurmuur | 0.8.1, INPUT policy DROP, 62 vrmr lines, 15 rules, service Livekit unused, 0 rules for 789x |
| Unpushed | sm-demo-app 19 commits (plans 02-04) + this plan's; vue-demo-app 9 commits |
## Ports used
7890/tcp on 127.0.0.1 (signal and RoomService, behind nginx `location ^~ /livekit/` and `livekit_admin_url`), 7891/tcp on all addresses (ICE-TCP), 7892/udp on every interface including 54.36.173.176 (ICE UDP mux). No collision with Go 8098, Nuxt 3098, Centrifugo 8010 or WavePath's 7880/7881/50000-60000 (check-deploy asserts it).
## What changed on rome (2026-11-01)
- **LiveKit:** `/srv/summercms-demo/livekit.yaml` (0640 root:summercms-demo, own key pair generated on rome), the same pair in `.env` (secret line count 1), `/etc/supervisor/conf.d/summercms-demo-livekit.conf`, program started alone with `supervisorctl update summercms-demo-livekit`. `curl 127.0.0.1:7890/` answers OK, `/rtc/validate` 401.
- **Centrifugo:** namespaces `["presentations","chat","user","video"]`, `checkconfig` passed, summercms-demo-centrifugo restarted. Backup `/srv/summercms-demo/centrifugo.json.bak-2026-11-01`.
- **VAPID:** public key, private key and subject `mailto:` + DEMO_ADMIN_EMAIL appended to `.env` (count 3, values non-empty: 87 and 43 characters), generated with `bin/demo.new herald:vapid-generate --json -q`.
- **Release** `c49fabf46c88` (sm-demo-app 6d726b4): 9 migrations (the chat family's tables), demo:seed created channels and history EN/PL, the visitor's welcome DM, forum categories and threads, friends and group, the moderator group demo-moderators and the moderation lists; binary and site swapped (`bin/demo.prev` 21e6abc585e6, `site/.output.prev`), summercms-demo and summercms-demo-site restarted, `websockets:health` reports the API key set.
- **nginx:** the new vhost installed, `nginx -t` passed, reloaded. Previous vhost `/root/demo.summercms.io.nginx.bak-2026-11-01`.
- **robots.txt:** `Disallow:` /_chat, /_chatchannels, /_chatbots, /_chatvideo, /_forum, /_userfriends and /livekit appended through `PUT /_seo/api/v1/robots` on loopback (bot switches untouched); the live file shows them with the Sitemap line. Previous rules `/root/summercms-demo-seo-robots.bak-2026-11-01.json`.
- **Firewall (by the user):** service `/etc/vuurmuur/services/sm-demo-livekit` (`RANGE="6;7891*1024:65535"`, `RANGE="17;7892*1024:65535"`, no COMMENT), rule `Accept service sm-demo-livekit from any to firewall(any) options log,loglimit="30"` on line 1 of rules.conf. `iptables -S` shows NFLOG `vrmr: ACCEPT` and NEWACCEPT for `--dport 7891` and `--dport 7892` (66 vrmr lines, 62 before); `nc -zv 54.36.173.176 7891` connects. It differs from the proposal in the service name (`sm-demo-livekit`, not `summercms-demo-livekit`), the missing COMMENT and the position (first, not appended); no `rules.conf.bak-*` is in `/etc/vuurmuur/rules/` (the pre-change file was the 15-line one probed on 2026-10-31). DEPLOY.md records it and its removal commands use the applied names.
- **WavePath untouched:** `wavepath-livekit` kept pid 1104 throughout (uptime 1 day 23:53 before, 2 days 0:09 after); no other non-demo program was restarted. Nothing was added to `/etc/livekit/livekit.yaml`.
Supervisor status after the release: summercms-demo, summercms-demo-site, summercms-demo-centrifugo and summercms-demo-livekit RUNNING.
## Checkpoint answers
- **Task 3:** deploy.
- **Task 5:** firewall done; the user applied the Vuurmuur rule, Claude made no firewall change and verified it read-only.
## Verification
- check-deploy: `check-deploy: ok`; negative copies (a /_herald location, LiveKit on another port, a UDP port range, bind 0.0.0.0, a real key, a cap mismatch, a wrong admin URL) each fail with their own line.
- Local LiveKit 1.13.1 with the example: signal on 127.0.0.1:7890 only, ICE-TCP on *:7891, UDP 7892 per interface, /rtc/validate 401.
- check-pins ok (25 modules); `build.sh release` → `build: ok release c49fabf46c88`; smoke ok; `go vet` and `go test -short ./...` green.
- check-live against the live demo after the release and again after the firewall rule: `check-live: ok` (banner, CTA, /backend 404, /smbackend, /chat /forum /friends /call/1 prompts, six chat-family 401s, /livekit/rtc/validate from LiveKit, /_herald 404, robots lines, /ws 101, Back in a browser).
- Acceptance greps: 6 chat-family locations, the program once, Vuurmuur 14+ times in DEPLOY.md. The plan's `grep -c 'location ^~ /livekit/'` prints 0 on this machine because `grep` is ugrep, which treats a mid-pattern `^` as an anchor (it also prints 0 for the existing `/_demo/` location); `grep -cF` prints 1.
- Robots state: updated, see above. Nothing left for the user from robots.
## Deviations from Plan
**1. [Rule 1 - Bug] check-live's /_herald check** (Task 2, cf08fb5). The first version failed on any JSON answer, but Nuxt answers its 404 as JSON to curl's `Accept: */*`. It now wants status 404 (Go would answer 401 without a JWT).
**2. [Rule 3 - Blocking] Release build prerequisites** (Tasks 2 and 4). `build.sh release` refused in Task 2: the uncommitted vue-demo-app gitlink (the plan's Task 4 bump), the user's untracked backup file (left untouched; the user removed it) and a stale ROADMAP snapshot (phase 20 progress lines). The snapshot was synced in Task 4 just before the build (6d726b4) so it matched the framework's master at release time.
**3. [Rule 2 - Missing critical] Runbook safety on a shared host** (Task 1). The LiveKit secret goes into the files through bash builtins and stdin only (the Centrifugo runbook's `sed` put secrets in argv); the substitution is anchored on the newline and indent so a comment holding the placeholder cannot take it; `supervisorctl update` names the program; the new Centrifugo file is created 0640 before `tee` writes it; the vhost is backed up before it is replaced (added to "Release").
**4. robots.txt through the loopback SEO API** (Task 4). DEPLOY.md's SEO section described a hand edit; the plan allowed an unattended path. The procedure is recorded in DEPLOY.md "Chat family".
**5. check-live also covers /call/1 and /_chat** (Task 1), because SC-2 and SC-3 name them; the plan's list had /chat, /forum, /friends and five API prefixes.
**Total deviations:** 5. **Impact:** none on the goal.
## Boot-log warnings (rome and local alike)
`golem15.chatchannels: ChannelAccessResolver is deny-all`, `golem15.forum: ForumAccessResolver is deny-all`, `golem15.chatbots: BotAuthResolver is deny-all` and `golem15.herald: PresenceGate is deny-all` appear at every boot, locally too (12 times each in the local go.log). They are logged before golem15.demo installs its resolvers in `lagoon.OnDatabase` (`plugins/demo/chat.go:68,86`); chatbots has no bot users and herald no UI (D-16). Plan 06's signed-in live checks confirm the wiring on rome.
## Known Stubs
None.
## Left for the user
Nothing required. Optional: keep a dated copy of the pre-change rules.conf if you want one next to the file (DEPLOY.md lists the 15 earlier rules). NAT'd-browser media is proven only by plan 06's live call; the TURN fallback needs its own decision.
## Next Phase Readiness
Plan 06 can run its live checks: the stale local demo servers from plan 04 are stopped, the live demo passes check-live, the media ports are reachable, and a signed-in member (Turnstile is closed in production, so a test account comes from the operator or the visitor account in rome's .env) can exercise chat, forum, friends and a two-browser DM call.
## Self-Check: PASSED
- sm-demo-app commits exist and are on origin: b5c639e, cf08fb5, c399bfd, 6d726b4, fcd8d95, f2e35c1 (remote master f2e35c1). vue-demo-app origin master 9357711.
- Files exist: deploy/supervisor/summercms-demo-livekit.conf, the modified files above.
- rome: summercms-demo-livekit RUNNING, `.env` LiveKit secret count 1, VAPID count 3, wavepath-livekit pid 1104 unchanged, check-live ok.

View File

@@ -0,0 +1,447 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 06
type: execute
wave: 6
depends_on: ["20-01", "20-02", "20-03", "20-04", "20-05"]
files_modified:
- ../sm-moderation-plugin/classes/filter_test.go
- ../sm-moderation-plugin/models/settings_test.go
- ../sm-moderation-plugin/plugin_test.go
- ../sm-chat-plugin/saving_test.go
- ../sm-forum-plugin/saving_test.go
- ../sm-demo-app/moderation_test.go
- ../sm-demo-app/chat_test.go
- ../sm-demo-app/video_test.go
- ../sm-demo-app/seed_chat_test.go
- ../sm-demo-app/reset_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
- ../sm-demo-app/plugins/demo/chatbots_test.go
- ../sm-demo-app/scripts/check-chat.cjs
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/README.md
- ../vue-chat-layer/vitest.config.ts
- ../vue-chat-layer/app/lib/chatEvents.test.ts
- ../vue-chat-layer/app/lib/chatFormat.test.ts
- ../vue-chat-layer/app/lib/chatText.test.ts
- ../vue-chat-layer/app/lib/chatUnread.test.ts
- ../vue-chat-layer/app/lib/realtime.test.ts
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
- ../vue-chat-layer/tests/client.test.ts
- ../vue-chat-layer/tests/store.test.ts
- ../vue-chatvideo-layer/tests/callMachine.test.ts
- ../vue-chatvideo-layer/tests/videoClient.test.ts
- ../vue-chatvideo-layer/tests/useCall.test.ts
- ../vue-forum-layer/tests/client.test.ts
- ../vue-userfriends-layer/tests/client.test.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts
autonomous: false
requirements: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-04, D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-13, D-14, D-15, D-17, D-18, D-20, D-21, D-22, D-23]
estimate:
tokens: 360000
raw_tokens: 360000
tasks: 8
confidence: low
must_haves:
truths:
- "SC-4: every behaviour row of 20-VALIDATION.md has a passing automated test under the name it lists, in the repository it lists; the new code of plans 01 to 05 is covered by unit tests in this last plan (project rule)."
- "D-18: TestMessageSaving* (sm-chat-plugin) and TestPostSaving* (sm-forum-plugin) pin: no listener gives byte-identical responses and stored rows; a rejecting listener on create, reply, ReplyTo and edit (chat) and thread, reply and edit (forum) gives 422 under the Rejection's field, writes and changes nothing, fires no post-save event and publishes nothing; a non-Rejection listener error keeps the 500 path; the existing php_method_map and golden tests pass unedited."
- "D-17: TestMatch*, TestParseList and TestFilterCheck* (sm-moderation-plugin) pin case-insensitive word and phrase matching with Unicode letter boundaries (Polish letters, digits, underscores), host, subdomain, www and scheme-less link matching, URL-prefix entries, the disabled switch, the recompile-on-change cache, the list limits in BeforeValidate and the locale fallback of the message; TestDemoModeration pins the demo wiring end to end through HTTP."
- "D-06, D-07, D-08, D-22: TestDemoChatContext*, TestDemoChannelAdapter, TestDemoChannelsSeededOnly, TestDemoChatBootstrap, TestDemoChatMembers, TestDemoDMs and TestDemoModeratorDelete pin membership (banned and deleted users excluded with live membership), the channel alias, refused channel creation, the bootstrap and members JSON shapes, DMs between any members, and moderator deletion limited to channel conversations."
- "D-11, D-12, D-23: TestDemoVideoStart pins that a DM call starts with a token and livekit_url against a fake RoomService and that the server does not refuse by conversation type (calls are limited to DMs by the UI); the vue-chat-layer tests pin that the conversation-actions slot renders only for DMs."
- "D-05: TestDemoCommands pins the catalog entries demo and tour and their ephemeral dispatch; the layer tests pin the command picker's dispatch path."
- "D-09: TestSeedChat pins EN and PL channels, threads, reactions, pins, forum categories and posts, friendships and the group, and idempotency; TestResetKeepsPlans pins that every chat-family table is emptied and reseeded and that moderation settings survive."
- "D-01, D-02, D-04: bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests run under vitest in vue-chat-layer with only import paths changed, next to new tests for the multi-channel store, threads, pins, link embeds, the client's refresh-and-retry and the anonymous no-bootstrap path."
- "D-13, D-14, D-15, D-21: the chatvideo machine and useCall (mocked livekit-client Room) tests, and the forum and friends client tests pass under their layers' test scripts."
- "SC-2: locally, scripts/check-local.sh chatlive callmedia passes: two browsers see each other's channel message live, and a DM call shows each side a playing remote video with fake media through the local LiveKit."
- "SC-2, SC-3: scripts/check-all.sh (including check-live against https://demo.summercms.io) prints check-all: ok, and the user confirms a two-browser chat and a DM call on the live demo."
artifacts:
- path: "../sm-moderation-plugin/classes/filter_test.go"
provides: "TestMatch*, TestParseList, TestFilterCheck*"
contains: "TestMatch"
- path: "../sm-chat-plugin/saving_test.go"
provides: "TestMessageSaving*"
contains: "TestMessageSaving"
- path: "../sm-forum-plugin/saving_test.go"
provides: "TestPostSaving*"
contains: "TestPostSaving"
- path: "../sm-demo-app/chat_test.go"
provides: "TestDemoChat*, TestDemoChannelsSeededOnly, TestDemoDMs, TestDemoModeratorDelete, TestDemoCommands"
contains: "TestDemoChannelsSeededOnly"
- path: "../sm-demo-app/video_test.go"
provides: "TestDemoVideoStart with a fake RoomService"
contains: "TestDemoVideoStart"
- path: "../sm-demo-app/scripts/check-chat.cjs"
provides: "two-browser realtime and DM call checks"
contains: "use-fake-device-for-media-stream"
- path: "../vue-chat-layer/tests/store.test.ts"
provides: "multi-channel store, threads, pins, commands"
contains: "describe"
key_links:
- from: "../sm-demo-app/scripts/check-all.sh"
to: "../sm-demo-app/scripts/check-live.sh"
via: "the gate runs every suite, the layers and the chat-family plugins, then check-live"
pattern: "check-live\\.sh"
- from: "../sm-demo-app/scripts/check-local.sh"
to: "../sm-demo-app/scripts/check-chat.cjs"
via: "check_chatlive and check_callmedia run the Playwright script with two contexts"
pattern: "check-chat\\.cjs"
- from: "../sm-demo-app/moderation_test.go"
to: "../sm-demo-app/plugins/demo/moderation.go"
via: "HTTP send with a banned word answers 422 through the demo listener and the moderation Filter"
pattern: "422"
prohibitions:
- statement: "Tests use no real secret, no production database and no network service except the Docker Postgres, the local dev stack and in-process fakes."
status: unresolved
verification: judgment
- statement: "No production behaviour changes in this plan except fixes for bugs the tests expose, within Task 7's fix budget (small, no feature, schema or contract change, at most one patch tag per repository and one redeploy), each in its own commit, named in the SUMMARY, and released through the Task 6 checkpoint; the app-wiring extraction in vue-demo-app is behaviour-preserving."
status: unresolved
verification: judgment
- statement: "Test-only commits in plugin and layer repositories get no tag; nothing is pushed before the Task 6 go-ahead."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): SC-4 says the new code has unit tests, delivered in the phase's last plan. The live demo shows the chat family working.
This plan's slice (plan 6 of 6, wave 6, per D-20 and the project rule that unit tests are the last plan): unit tests for everything plans 01 to 05 built, in Go, the four layers and the site, then a local two-browser chat and call check and the live verification, ending with the user's look at the live demo.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Where tests live:**
- plugin seam tests next to the plan 01 smoke tests;
- the demo's database tests in the root package (they compile every plugin through PluginIDs and the shared Postgres harness, as 19.4.1 did), with pure demo classes tested in plugins/demo/classes;
- bm's chat tests next to the ported sources in vue-chat-layer/app/lib (copied with only import paths changed, the 19.4.1 vue-demo-app pattern), new layer tests in tests/.
- **Fake LiveKit:** TestDemoVideoStart points `golem15.chatvideo.livekit_admin_url` at an httptest server answering the Twirp RoomService calls Start makes (CreateRoom, and ListParticipants if used), so chatvideo's real code path runs without a LiveKit.
- **useCall test:** livekit-client is replaced with vi.mock of a minimal Room (connect, disconnect, localParticipant setters, event emitter). The real SDK is exercised by check-chat.cjs against the local LiveKit with fake media.
- **Site tests:** the hook factories in vue-demo-app's app/plugins/auth.ts (token, refresh and unauthorized for chat; token getters; the friends directory mapping) move unchanged into app/utils/communityAuth.ts, so vitest can test them without Nuxt.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
## Artifacts this phase produces (this plan)
- Go tests:
- sm-moderation-plugin `TestMatchWords`, `TestMatchLinks`, `TestParseList`, `TestFilterCheckCache`, `TestFilterCheckDisabled`, `TestSettingsLimits`, `TestBootPublishesFilter`;
- sm-chat-plugin `TestMessageSavingNoListener`, `TestMessageSavingRejects`, `TestMessageSavingOtherError`;
- sm-forum-plugin `TestPostSavingNoListener`, `TestPostSavingRejects`;
- sm-demo-app `TestDemoModeration`, `TestDemoChatContext*`, `TestDemoChannelAdapter`, `TestDemoChannelsSeededOnly`, `TestDemoChatBootstrap`, `TestDemoChatMembers`, `TestDemoDMs`, `TestDemoModeratorDelete`, `TestDemoCommands`, `TestDemoVideoStart`, `TestSeedChat`, extended `TestResetKeepsPlans`, `TestRealtimeRoutes`, `TestRealtimeMemberToken`, `TestBootAllPlugins`.
- Layer tests: vue-chat-layer (ported bm tests plus `store.test.ts`, `client.test.ts`, `linkEmbeds.test.ts`), vue-chatvideo-layer (`callMachine`, `videoClient`, `useCall`), vue-forum-layer and vue-userfriends-layer client tests.
- Site: `app/utils/communityAuth.ts` with its test.
- Scripts: check-chat.cjs modes `--two` (realtime) and `--call` (fake media); check-local checks `chatlive` and `callmedia`; check-all.sh steps for the chat-family plugins and the four layers.
<objective>
Cover the phase's new code with unit tests, prove two-browser chat and DM calls locally, and verify the live demo with the user.
Purpose: SC-4 (unit tests in the last plan) and the live proof of SC-2 and SC-3 for every decision the phase implemented.
Output: test commits in eight Go and five frontend repositories, script commits in sm-demo-app, pushes after the go-ahead, and the user's sign-off on the live demo.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-VALIDATION.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-RESEARCH.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-01-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-02-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-03-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-04-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md
Repos: ../sm-moderation-plugin, ../sm-chat-plugin, ../sm-forum-plugin, ../sm-demo-app (root tests, plugins/demo, scripts), ../sm-demo-app/vue-demo-app, ../vue-chat-layer, ../vue-chatvideo-layer, ../vue-forum-layer, ../vue-userfriends-layer. bm test sources (read-only): ../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts.
Rules: no co-author tags; stage by explicit path; Docker tests skip under -short and the TestMain calls flag.Parse and testing.Short (CLAUDE.md); if the Docker socket is denied, run through `sudo -u golem`; production code changes only for bugs the tests expose and only within the fix budget in Task 7 (own commit each, named in the SUMMARY; anything larger becomes a todo and decimal-phase candidate); nothing is pushed before Task 6.
<interfaces>
sm-demo-app test harness: testmain_test.go `bootDemo(t, environ, set) *demotest.Demo`; internal/demotest (App, DB, Options{PluginIDs, Environ, Set}); existing tests TestGoModRequiresTags, TestBootAllPlugins (wantPlugins), TestRealtimeRoutes (realtimeApp, realtimeRoutes, wantNamespaces), TestResetKeepsPlans, TestSeedRoadmapUpsert; smoke tests from plan 02: TestDemoChatTracer, TestDemoModerationSmoke, TestSeedChatSmoke (chat_smoke_test.go).
Plugin harnesses: chat bootWeb, frontendToken, call, conversation, count, memory (lighthouse MemoryDriver publications); forum bootWeb, call, count; plan 01 smoke tests TestMessageSavingTracer, TestPostSavingTracer, TestFilterSmoke.
Demo classes (plan 02): DemoContext, ChannelContext, ChatAccess, ForumAccess with WithMembership and MemberFunc injection; chat_api.go bootstrap and members; chatbots.go demo and tour; moderation.go listeners; realtime_access.go guard.
Layers (plans 03 and 04): vue-chat-layer createChatClient, useChat, linkify, registerEmbedRenderer, onUserEvent, slots; vue-chatvideo-layer callMachine (initialCallState, transition), createVideoClient, videoSessionChannel, useCall; createForumClient; createFriendsClient.
Scripts: check-local.sh (visitor_jwt, throwaway registration helper, ALL_CHECKS, NODE_PATH="$(npm root -g)" for Playwright scripts), check-chat.cjs (plan 03 single-browser), check-all.sh (steps, "check-all: ok").
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: one banned word travels from the admin settings through the filter, the demo listener and the chat seam to a 422 in a member's request, under test</name>
<precondition>`docker info` exits 0; the plan 05 SUMMARY exists.</precondition>
<files>../sm-moderation-plugin/classes/filter_test.go, ../sm-demo-app/moderation_test.go</files>
<read_first>../sm-moderation-plugin/classes/filter.go and filter_smoke_test.go, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/chat_smoke_test.go (TestDemoModerationSmoke), ../sm-demo-app/testmain_test.go, 20-VALIDATION.md (D-17 rows)</read_first>
<action>Per D-17, D-18, SC-4.
- sm-moderation-plugin classes/filter_test.go, table tests for the pure functions:
- TestMatchWords: case-insensitive; a word inside a longer word does not match; phrases; Polish letters on both sides of a boundary (for example "żółw" against "żółwik" and "żółw!"); digits and underscores count as word characters; QuoteMeta'd specials such as "c++".
- TestMatchLinks: a host entry matches the bare domain, www., http and https URLs, subdomains, upper case and trailing paths; "notbad.example" does not match "bad.example"; a URL-prefix entry matches only below its path.
- TestParseList: trims, lowercases, drops empty lines and duplicates.
- sm-demo-app moderation_test.go `TestDemoModeration` (Docker), end to end through HTTP:
- after demo:seed, update golem15_moderation_settings with banned_words "blockedword" and banned_links "bad.example";
- a member's message, reply and edit containing either answer 422 with errors.content, and the message count does not change;
- a forum thread title answers 422 errors.title and a reply body 422 errors.body;
- after changing the list to another word without restarting, the old word is accepted (cache refresh);
- with enabled false, everything is accepted.
Commit per repository (`test: ...`).</action>
<verify>
<automated>go -C ../sm-moderation-plugin test -count=1 ./... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^TestDemoModeration$' -v . 2&gt;&amp;1 | grep -q -- '^--- PASS: TestDemoModeration'</automated>
<fails_when>Non-zero exit from the moderation suite (a boundary, host or cache case wrong), or no "--- PASS: TestDemoModeration" line (skipped without Docker, banned content stored, or a list change not picked up).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '^func TestMatch' ../sm-moderation-plugin/classes/filter_test.go` prints at least 2.
- `grep -c 'żółw' ../sm-moderation-plugin/classes/filter_test.go` prints at least 1.
</acceptance_criteria>
<done>The D-17/D-18 chain is pinned from the settings row to the HTTP answer.</done>
</task>
<task type="auto">
<name>Task 2: Seam and moderation plugin unit tests: parity without a listener, refusals on every write path</name>
<files>../sm-chat-plugin/saving_test.go, ../sm-forum-plugin/saving_test.go, ../sm-moderation-plugin/models/settings_test.go, ../sm-moderation-plugin/plugin_test.go</files>
<read_first>../sm-chat-plugin/saving_smoke_test.go, http_tracer_test.go, harness_test.go (memory driver), classes/attachments.go (ReplyTo), ../sm-forum-plugin/saving_smoke_test.go, http_tracer_test.go, ../sm-moderation-plugin/models/settings.go, plugin.go, updates/*.go, ../sm-sitemap-plugin/models/definition_test.go (settings test style)</read_first>
<action>Per D-18, D-17.
- sm-chat-plugin saving_test.go:
- TestMessageSavingNoListener: the same send, reply, edit and ReplyTo requests answer the same status and body keys as before the seam, and MessageCreated and MessageUpdated still fire;
- TestMessageSavingRejects: table over send, reply (POST replies), ReplyTo (service) and edit. Each gives 422 under the Rejection field (and "content" when the field is empty), no row inserted or changed, no MessageCreated or MessageUpdated fired (a counting listener), and no publication on the memory driver;
- TestMessageSavingOtherError: a plain error from a listener gives 500 with the generic body and nothing stored;
- the event carries Op, ConversationID, ActorID, RootID, MessageID and the trimmed Content.
- sm-forum-plugin saving_test.go: TestPostSavingNoListener and TestPostSavingRejects over thread, reply and edit (field title or body, nothing stored, content_html unchanged on a refused edit, Op values).
- sm-moderation-plugin:
- models/settings_test.go TestSettingsLimits: too many entries, too long entries and a bad link entry refused with their lang keys; valid lists saved;
- plugin_test.go TestBootPublishesFilter (Docker, with the sitemap-style harness): migrations create the singleton row, Boot publishes *classes.Filter, and Check on a clean text gives nil.
Keep php_method_map_test.go and golden_test.go untouched in both content plugins. Commit per repository.</action>
<verify>
<automated>go -C ../sm-chat-plugin test -count=1 ./... &amp;&amp; go -C ../sm-forum-plugin test -count=1 ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 ./... &amp;&amp; go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSaving' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: TestMessageSaving' | grep -qvx '[0-2]'</automated>
<fails_when>Non-zero exit from any of the three suites (parity broken, a write path that bypasses the seam, an event fired after a refusal), or fewer than three passing TestMessageSaving* lines (tests skipped without Docker).</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-chat-plugin diff HEAD~1 --stat -- php_method_map_test.go golden_test.go` and the same in ../sm-forum-plugin print nothing.
- `grep -c 'ReplyTo' ../sm-chat-plugin/saving_test.go` prints at least 1.
</acceptance_criteria>
<done>The seams' contract (silent without listeners, total refusal with one) and the moderation plugin's settings and boot are pinned.</done>
</task>
<task type="auto">
<name>Task 3: Demo host unit tests: context, adapter, bootstrap, DMs, moderator delete, commands, video start, realtime, seed and reset</name>
<files>../sm-demo-app/plugins/demo/classes/chat_context_test.go, ../sm-demo-app/plugins/demo/chatbots_test.go, ../sm-demo-app/chat_test.go, ../sm-demo-app/video_test.go, ../sm-demo-app/seed_chat_test.go, ../sm-demo-app/reset_test.go, ../sm-demo-app/realtime_test.go, ../sm-demo-app/boot_test.go</files>
<read_first>../sm-demo-app/plugins/demo/{chat.go,chat_api.go,chatbots.go,moderation.go,realtime_access.go,routes.go,classes/chat_context.go,classes/forum_access.go,console/seed_chat.go,console/seed_forum.go,console/seed_friends.go,console/reset.go}, ../sm-demo-app/{chat_smoke_test.go,demo_api_test.go,realtime_test.go,reset_test.go,seed_test.go,boot_test.go,testmain_test.go}, ../sm-demo-app/plugins/golem15/chatvideo/classes/livekit.go (RoomAPI, NewProductionRooms, FakeRooms) and boot.go, ../sm-bm-app/realtime_test.go, 20-VALIDATION.md</read_first>
<action>Per D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-22, D-23, SC-1.
- plugins/demo/classes/chat_context_test.go (pure, with WithMembership and MemberFunc stubs): constants, BroadcastChannel never empty for either alias, CanModerateMessage only for moderator-group members in non-DM conversations, RequireLiveMembership true, ChatAccess CanManageContext always false and CanAccessContext only for demo/1 members, ForumAccess read, post and manage rules.
- plugins/demo/chatbots_test.go: TestDemoCommands. Triggers demo and tour, IsAvailable false for actor 0, Handle returns an ephemeral result whose text lists the site paths.
- chat_test.go (Docker), with a seeded demo and members created in the test:
- TestDemoChatContextMembership: a banned user and a deleted user are not members; a member banned after joining loses access at the next request (live membership);
- TestDemoChannelAdapter: the conversation of each seeded channel resolves through the channel alias;
- TestDemoChannelsSeededOnly: POST channels is refused, join and leave work;
- TestDemoChatBootstrap: the JSON keys and types of /_demo/api/v1/chat for a member, {member: false} for a non-member, 401 without a JWT;
- TestDemoChatMembers: excludes the caller and personas count as members;
- TestDemoDMs: any two members open a DM, and opening it again returns the same id;
- TestDemoModeratorDelete: a moderator-group member deletes another member's channel message (200) but not a DM message of others (404); a plain member gets 404;
- TestDemoCommands over HTTP: GET /_chatbots/api/v1/commands lists demo and tour; POST dispatch "/demo" answers mode ephemeral; "/nope" answers 422 errors.input.
- video_test.go (Docker) TestDemoVideoStart: an httptest server answers the Twirp RoomService calls chatvideo's RoomAPI makes, with livekit_admin_url pointing at it and test keys in the environment. POST /_chatvideo/api/v1/conversations/{dm}/video/start answers 2xx with token and livekit_url, the token's claims grant the room for that user, and a second start returns the same session. A start in a seeded channel conversation is not refused by type (D-23 UI-only); assert the status the plugin gives with the channel's participant count and max_participants 4, and record it.
- realtime_test.go: TestRealtimeRoutes keeps the middleware list from plan 02. New TestRealtimeMemberToken (Docker): a member's JWT gives 200 with a token, a banned user's gives 403 (or the guard's documented status), no JWT gives 401.
- seed_chat_test.go TestSeedChat: exact counts per EN and PL channel, threads with replies and summary rows consistent, reactions, pins, three forum categories with content_html, friendships, the group, the visitor's DM and pending request when the visitor is set, the moderator group, and idempotency (every new step reports exists on the second run).
- reset_test.go: TestResetKeepsPlans also asserts that every chat-family table named in resetTables is empty after the wipe and repopulated after the reseed, and that golem15_moderation_settings and the userfriends context row are unchanged.
- boot_test.go: the chat-family route prefixes, and jwt.auth on GET /_demo/api/v1/chat and /chat/members.
Commit `test: demo chat family`.</action>
<verify>
<automated>go -C ../sm-demo-app vet . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 . ./plugins/demo/... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^(TestDemoChannelsSeededOnly|TestDemoVideoStart|TestSeedChat|TestResetKeepsPlans|TestRealtimeMemberToken)$' -v . 2&gt;&amp;1 | grep -c -- '^--- PASS: Test' | grep -qx 5</automated>
<fails_when>Non-zero exit or a FAIL line from the full app suite, or fewer than five "--- PASS" lines for the named tests (skipped without Docker, a refused channel create that succeeds, a video start error against the fake RoomService, a seed count mismatch, or a reset leaving chat rows).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '^func Test' ../sm-demo-app/chat_test.go` prints at least 7.
- `go -C ../sm-demo-app test -count=1 -short . ./plugins/demo/...` passes without Docker.
</acceptance_criteria>
<done>Every demo host decision of the phase is pinned by a unit or database test.</done>
</task>
<task type="auto">
<name>Task 4: Layer and site unit tests: bm's chat tests on the layer, new store and client tests, the call machine and the app wiring</name>
<files>../vue-chat-layer/vitest.config.ts, ../vue-chat-layer/app/lib/chatEvents.test.ts, ../vue-chat-layer/app/lib/chatFormat.test.ts, ../vue-chat-layer/app/lib/chatText.test.ts, ../vue-chat-layer/app/lib/chatUnread.test.ts, ../vue-chat-layer/app/lib/realtime.test.ts, ../vue-chat-layer/app/lib/linkEmbeds.test.ts, ../vue-chat-layer/tests/client.test.ts, ../vue-chat-layer/tests/store.test.ts, ../vue-chatvideo-layer/tests/callMachine.test.ts, ../vue-chatvideo-layer/tests/videoClient.test.ts, ../vue-chatvideo-layer/tests/useCall.test.ts, ../vue-forum-layer/tests/client.test.ts, ../vue-userfriends-layer/tests/client.test.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/lib/realtime.test.ts</files>
<read_first>../sm-bm-app/frontend/src/lib/{chatEvents,chatFormat,chatText,chatUnread,realtime}.test.ts and stores/chat.test.ts, ../sm-bm-app/frontend/vite.config.ts (its test block), ../vue-chat-layer/app/lib/*.ts, app/composables/useChat.ts, tests/smoke.test.ts, ../vue-chatvideo-layer/app/lib/*.ts and app/composables/useCall.ts, ../vue-forum-layer/app/utils/forumClient.ts, ../vue-userfriends-layer/app/utils/friendsClient.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, vitest.config.ts, app/lib/realtime.test.ts</read_first>
<action>Per D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23.
- vue-chat-layer:
- vitest.config.ts includes app/**/*.test.ts and tests/**/*.test.ts in the node environment, with aliases so bm's tests run with only import paths changed;
- copy bm's chatEvents, chatFormat, chatText, chatUnread and realtime tests next to the ports;
- linkEmbeds.test.ts: linkify escapes, only http(s), rel and target set, the registry order, max_embeds;
- client.test.ts: every endpoint's URL and method, 401 refresh-and-retry once, unauthorized after a failed refresh, the 422 mapping, multipart upload;
- store.test.ts (bm's chat.test.ts rewritten for the useState composable, with a fake client and a fake realtime):
- the bootstrap fills channels and DMs; an empty hostApiBase starts nothing; a non-member bootstrap starts nothing (D-04);
- opening a channel lazy-joins; a realtime message increments unread in other conversations and appends in the open one;
- thread replies and counts, pins load, reactions toggle;
- a "/" input dispatches instead of posting and the ephemeral answer stays local (D-05);
- polling starts when realtime is unavailable;
- onUserEvent receives a video:* event the store does not handle;
- the conversation-actions slot renders only for DMs (mount ChatHeader with @vue/test-utils only if it is already in the workspace lockfiles, else test the computed visibility helper the header uses).
- vue-chatvideo-layer:
- callMachine.test.ts: every transition, including failure reasons;
- videoClient.test.ts: URLs, auth, videoSessionChannel;
- useCall.test.ts: vi.mock livekit-client with a fake Room; start, connect, a remote track event adds a tile, mute and camera toggles call the local participant, leave disconnects and posts leave, an ended event on the session channel ends the call, and a 422 start fails with the too-many reason.
- vue-forum-layer and vue-userfriends-layer: extend the client tests to every method and error mapping.
- vue-demo-app: move the hook factories from app/plugins/auth.ts unchanged into app/utils/communityAuth.ts (`createChatAuth`, `tokenGetter`, `friendsDirectory`). auth.ts imports them. communityAuth.test.ts covers the token read, refresh storing the new token, unauthorized clearing the cookie, and the directory mapping. app/lib/realtime.test.ts still passes through the re-export.
Commit per repository (`test: ...`; the vue-demo-app extraction as `refactor: community auth hooks into app/utils` before its test commit).</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; pnpm --dir ../vue-chatvideo-layer test &amp;&amp; pnpm --dir ../vue-forum-layer test &amp;&amp; pnpm --dir ../vue-userfriends-layer test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
<fails_when>Non-zero exit from any layer's test script, the vue-demo-app vitest suite, or the app build after the hook extraction.</fails_when>
</verify>
<acceptance_criteria>
- `ls ../vue-chat-layer/app/lib/*.test.ts | wc -l` prints at least 6.
- `diff <(sed -n '/^describe/,$p' ../sm-bm-app/frontend/src/lib/chatText.test.ts) <(sed -n '/^describe/,$p' ../vue-chat-layer/app/lib/chatText.test.ts)` prints nothing (only imports differ).
- `grep -c "vi.mock('livekit-client'" ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
- `grep -c 'from .*communityAuth' ../sm-demo-app/vue-demo-app/app/plugins/auth.ts` prints 1.
</acceptance_criteria>
<done>The four layers and the site's wiring are covered by unit tests, with bm's chat tests running unchanged on the port.</done>
</task>
<task type="auto">
<name>Task 5: Two browsers locally: live channel messages and a DM call with fake media, and the phase gate in check-all</name>
<precondition>`../sm-demo-app/scripts/dev.sh up` runs with LiveKit on 127.0.0.1:7890; the global Playwright resolves (`NODE_PATH="$(npm root -g)" node -e "require('playwright')"` exits 0).</precondition>
<files>../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/check-all.sh, ../sm-demo-app/README.md</files>
<read_first>../sm-demo-app/scripts/check-chat.cjs (plan 03), check-remote.cjs, lib/browser.cjs, check-local.sh (visitor_jwt, throwaway registration helper, check_remote), check-all.sh (steps and plugin dirs), README.md (checks section)</read_first>
<action>Per SC-2, D-11, D-13, D-23.
- check-chat.cjs: add `--two` and `--call` modes. Each launches Chromium with `--use-fake-ui-for-media-stream` and `--use-fake-device-for-media-stream` and opens two contexts with two JWTs (DEMO_JWT and DEMO_JWT2).
- `--two`: both open /chat#general; A sends a unique text; B sees it within 10 s without reload; B reacts and A sees the reaction; B types and A sees the typing indicator.
- `--call`: A opens a DM with B (POST /_chat/api/v1/contexts/demo/1/dm through page.request with A's token) and opens it on /chat. The DM header shows the call button and no call button shows in #general (D-23). A clicks it; B's page shows the incoming prompt within 10 s and accepts. Both call pages reach the connected state and each shows one remote tile whose video element has videoWidth above 0 within 20 s. A mutes and B sees the muted marker if the tile shows one. Both leave and the session ends (GET status).
- Print `check-chat: ok two` or `check-chat: ok call`.
- check-local.sh: `check_chatlive` and `check_callmedia` take the visitor JWT and a throwaway user's JWT, run the script in the matching mode, and join ALL_CHECKS and the header list.
- check-all.sh, new steps after the existing plugin suites:
- `go test ./...` in ../sm-chat-plugin, ../sm-forum-plugin and ../sm-moderation-plugin, and `go test -short ./...` in chatchannels, chatbots, chatvideo, herald and userfriends (directories overridable by env, as USER_PLUGIN_DIR is);
- `pnpm test` in the four layer checkouts (overridable);
- the local browser step runs `check-local.sh chatlive callmedia` alongside back and remote.
Update the header comment and README's checks section.
Commit `test: two-browser chat and call checks; phase gate covers the chat family`.</action>
<verify>
<automated>../sm-demo-app/scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote</automated>
<fails_when>The last line is not "check-local: ok ..." listing the checks; a "check-local: chatlive failed" or "callmedia failed" line (message not delivered live, no incoming prompt, no remote video within 20 s, or a call button in a channel) or any other named check failing.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'use-fake-device-for-media-stream' ../sm-demo-app/scripts/check-chat.cjs` prints at least 1.
- `grep -cE 'chatlive|callmedia' ../sm-demo-app/scripts/check-local.sh` prints at least 4.
- `grep -c 'sm-moderation-plugin' ../sm-demo-app/scripts/check-all.sh` prints at least 1.
</acceptance_criteria>
<done>Two-browser realtime chat and a DM video call are proven automatically on the local stack, and the phase gate runs every new suite.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 6: Go-ahead to push the test commits (and to release fixes if the tests found bugs)</name>
<read_first>Claude's table: per repository the unpushed commits (`git log origin/<branch>..HEAD --oneline`), which are test-only and which are fixes; for each fix, the proposed patch tag (next free number from the remote) and the pin bumps and redeploy it needs (plan 05 runbook); failures outside Task 7's fix budget are listed as todos, not as fixes</read_first>
<acceptance_criteria>
- The user chose one option and it is recorded in the SUMMARY.
</acceptance_criteria>
<decision>Push the test commits, and tag and redeploy fixes if there are any?</decision>
<context>Test-only commits get no tag (19.4.1 precedent). A production fix in a shared plugin or layer needs a new patch tag (one-way), a go.mod or package.json pin bump in the demo and a redeploy through DEPLOY.md; the table lists each case. If there are no fixes, this only pushes.</context>
<options>
<option id="push">
<name>Push (and release the listed fixes)</name>
<pros>Tests are on origin; any fix reaches the live demo before the final check.</pros>
<cons>Fix tags are permanent.</cons>
</option>
<option id="push-tests-only">
<name>Push test commits only</name>
<pros>No new tags.</pros>
<cons>Listed fixes stay local and the live demo keeps the bug.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>Nothing leaves this machine.</pros>
<cons>check-all's suites still run locally, but origin lacks the tests.</cons>
</option>
</options>
<resume-signal>Reply "push", "push-tests-only" or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 7: Push, release any fixes, and run the phase gate against the live demo</name>
<reversibility rating="one-way">Only when fixes are released: their patch tags are cached by module proxies and lockfiles.</reversibility>
<precondition>Task 6 answered "push" or "push-tests-only".</precondition>
<files>../sm-demo-app/README.md</files>
<read_first>The Task 6 table and answer, ../sm-demo-app/DEPLOY.md (Release, Verify)</read_first>
<action>Per SC-2, SC-3, SC-4.
- Push the test commits of every repository (plugins and layers first, then vue-demo-app, then sm-demo-app).
- For "push" with fixes, in one release batch:
- tag each fixed plugin or layer with the next patch (annotated, on the verified HEAD) and push;
- bump the demo's go.mod (GOWORK=off go get) or package.json pins in one `chore: bump <name> to <tag>` commit each;
- push vue-demo-app before the sm-demo-app pointer bump;
- release on rome through DEPLOY.md.
- Run `scripts/check-all.sh --local` (every suite, the browser checks, check-live). Fix a failure only within the fix budget below, then run it again. Do not loop past the budget.
- Fix budget. It bounds the whole plan: fixes made in Tasks 1 to 5, in this task and for the Task 8 findings.
- Source: a fix is allowed only for a failure found by this plan's own tests or checks (Tasks 1 to 5, check-all, check-live, the Task 8 UAT).
- Size: each fix is small. It adds no feature, changes no schema or migration, and changes no API route, payload, event, config key, hook, prop or other published contract.
- Release: at most one new patch tag per affected repository and at most one redeploy on rome in this plan. Every in-budget fix goes into that one release batch. Under "push-tests-only", fixes are committed but not tagged or deployed, and each one is recorded as a todo.
- After the release: a fix that needs no tag or redeploy (a check script or a test in sm-demo-app) is still allowed if it is small. Anything that needs a second tag of the same repository or a second redeploy is outside the budget.
- Outside the budget: do not fix it in this plan. Record it as a todo in `.planning/todos/pending/` with its g15office task (`.planning/G15OFFICE.md` rules). List it in the SUMMARY as a decimal-phase candidate (for example `/msd-phase --insert 20.1`). Leave the check-all result as it is, and stop at Task 8's checkpoint with the open failures listed for the user.
- Record in README's checks section what the gate now covers.</action>
<verify>
<automated>../sm-demo-app/scripts/check-all.sh --local 2&gt;&amp;1 | tail -1 | grep -qx 'check-all: ok'</automated>
<fails_when>The last line is not "check-all: ok" (a suite, the release build, check-pins, smoke, check-deploy, a browser check or check-live failed; the failing step is named in its own "check-all: <step> failed" line). A failure outside the fix budget keeps this red: it is recorded as a todo and the plan stops at Task 8 instead of fixing further.</fails_when>
</verify>
<acceptance_criteria>
- For each repository touched in this plan, `git ls-remote origin` shows the default branch at the local HEAD (or the SUMMARY records the "hold" answer).
- Any fix tag equals its pushed HEAD and the demo pins it (`check-pins.sh` prints `check-pins: ok`).
- The fix budget held. The SUMMARY lists every production fix with its commit and the failure that found it, every tag created in this plan (no repository twice), and at most one DEPLOY.md release. `git -C <repo> show --stat <fix>` for each listed fix touches no migration (`updates/`) and no parity test file (`php_method_map_test.go`, `golden_test.go`).
- Either the last line of check-all is `check-all: ok`, or every remaining failure is recorded as a todo in `.planning/todos/pending/` with a `g15office:` ID and named as a decimal-phase candidate in the SUMMARY, and execution stopped at Task 8 with those failures shown to the user.
</acceptance_criteria>
<done>Every test is on origin, every in-budget fix is live, and the phase gate is green against the live demo, or each remaining failure is recorded as a todo and decimal-phase candidate for the user at Task 8.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 8: The user checks chat and a DM video call on the live demo with two browsers</name>
<read_first>The Task 7 check-all output, ../sm-demo-app/DEPLOY.md (Verify), 20-VALIDATION.md (Manual-Only Verifications)</read_first>
<acceptance_criteria>
- The user replied "approved". Or each issue they listed was fixed within Task 7's fix budget (released in the plan's one release batch only if it is still unused, check-live green again) and the user approved. Or the issues outside the budget are recorded as todos and decimal-phase candidates in the SUMMARY and the user approved closing the plan with them open.
</acceptance_criteria>
<what-built>https://demo.summercms.io with chat (channels, DMs, threads, pins, reactions, attachments, slash commands, floating panel), forum, friends and DM video calls through the demo's own LiveKit, moderation of banned words and links, EN and PL, and the tour cards. Anonymous visitors get register prompts on /chat and /forum.</what-built>
<how-to-verify>
1. Browser A: log in as the visitor (DEMO_VISITOR_EMAIL; the password is SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD in /srv/summercms-demo/.env on rome). Browser B (another profile or device on a different network if possible, for example a phone on mobile data): register a new account.
2. Both open /chat, then #general. Send a message from A and see it arrive in B without reloading. Reply in a thread, react, and pin if allowed. Type "/demo" and see the answer only in your own window.
3. Add a banned word in the admin (log in at /smbackend with the admin credentials in /root/summercms-demo-admin.txt on rome, Settings › Moderation), then send a message containing it: it is refused with a message. Remove the word again.
4. From A, open a DM with B's account and press the call button (it must not appear in #general). In B, accept the prompt. Both see and hear each other; mute, toggle the camera, then leave.
5. Check /forum and /friends as a member and anonymously (register prompt), the floating chat panel on /blog, and the Polish language switch.
</how-to-verify>
<resume-signal>Reply "approved", or describe what is wrong.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| test harnesses to Docker Postgres and local services | test data only, no production systems |
| browser checks to the local and live demo | throwaway accounts, the visitor credentials from .env |
| build machine to origin and rome (fix releases only) | tags and releases after the go-ahead |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-35 | Information disclosure | credentials in tests and check output | medium | mitigate | tests use generated users and test keys; check scripts read the visitor password from .env and never print it; the live UAT names the .env key, not the value |
| T-20-36 | Tampering | regressions hiding behind skipped tests | medium | mitigate | verify commands count "--- PASS" lines so a Docker-less skip fails the gate; check-all runs full (non-short) suites |
| T-20-37 | Denial of service | throwaway accounts on the live demo | low | accept | the live check registers no accounts (check-live is read-only); the human UAT creates one, removed by the reset job |
| T-20-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new package: tests use vitest 3.2.7 and the existing workspace pins; @vue/test-utils only if already in a lockfile (otherwise the helper is tested directly); Playwright is the existing global install |
</threat_model>
<verification>
- Task 1: moderation suite and TestDemoModeration.
- Task 2: chat, forum and moderation suites with Docker; parity tests unedited.
- Task 3: sm-demo-app full suite plus the named database tests.
- Task 4: four layer suites, vue-demo-app vitest and build.
- Task 5: check-local with the two-browser chat and call checks.
- Task 6: push and fix-release decision recorded.
- Task 7: check-all --local prints check-all: ok against the live demo.
- Task 8: the user approves the live demo.
</verification>
<success_criteria>
SC-4: the phase's new code is unit-tested in its last plan. SC-2 and SC-3 are proven live, automatically through check-all and check-live and by the user through a two-browser chat and a DM video call on https://demo.summercms.io.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md` when done: the test names per repository, bugs found and fixed (commits and tags), what TestDemoVideoStart recorded for a channel start, the check-all output, both checkpoint answers and the user's UAT notes.
</output>

View File

@@ -0,0 +1,328 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 06
subsystem: tests, sm-demo-app checks, vue-demo-app header
tags: [tests, vitest, testcontainers, playwright, livekit, moderation, chat, forum, check-all, uat]
requires:
- phase: 20-01
provides: "MessageSaving and PostSaving seams, golem15.moderation Filter"
- phase: 20-02
provides: "the demo host (contexts, policies, bootstrap, seed, reset, realtime guard)"
- phase: 20-03
provides: "vue-chat-layer v0.1.0"
- phase: 20-04
provides: "vue-forum-layer, vue-userfriends-layer and vue-chatvideo-layer v0.1.0"
- phase: 20-05
provides: "the chat family live on demo.summercms.io with its own LiveKit"
provides:
- "Unit and database tests for the phase's new code in the plugin, layer and app repos that own it (SC-4)"
- "check-chat.cjs --two and --call; check-local chatlive and callmedia (two browsers, a DM call with fake media through the local LiveKit)"
- "check-all.sh runs the chat family's plugin suites, the four layer suites and the two-browser checks"
- "The live header: every site link on the left (menu entries, Chat, Forum, member links), only the language switcher and Log in/Log out on the right"
affects: [phase-20 verification]
actuals:
tokens: 82300 # chars/4 over the realized diffs of the nine repos (329253 chars, go.sum and the roadmap snapshot excluded)
tasks: 8
commits: 0 # MEASURED in summercms.go: git rev-list --count 8a3ac87..HEAD before this SUMMARY commit (docs only here); code commits: 18 across nine sibling repos (table below)
plan_head_before: 8a3ac87abb6caa5d8d668fa9ead52fb0dcc24ee9 # summercms.go
plan_head_after: 8a3ac87abb6caa5d8d668fa9ead52fb0dcc24ee9 # summercms.go before this SUMMARY commit
tech-stack:
added:
- "sm-moderation-plugin: github.com/testcontainers/testcontainers-go v0.44.0 and github.com/jackc/pgx/v5 v5.10.0 as test-only dependencies (the decided versions the other plugins pin)"
patterns:
- "Fake LiveKit RoomService for Go tests: an httptest server answering /twirp/livekit.RoomService/* with empty protobuf bodies, so chatvideo's real RoomServiceClient path runs"
- "Nuxt composables tested outside Nuxt by stubbing the auto-imported globals (useState, useNuxtApp, useChatApi, ...) over a fresh state per test"
- "Two-browser Playwright checks with --use-fake-ui-for-media-stream and --use-fake-device-for-media-stream against the local LiveKit"
key-files:
created:
- ../sm-moderation-plugin/classes/filter_test.go
- ../sm-moderation-plugin/classes/filter_internal_test.go
- ../sm-moderation-plugin/models/settings_test.go
- ../sm-moderation-plugin/plugin_test.go
- ../sm-chat-plugin/saving_test.go
- ../sm-forum-plugin/saving_test.go
- ../sm-demo-app/moderation_test.go
- ../sm-demo-app/chat_test.go
- ../sm-demo-app/video_test.go
- ../sm-demo-app/seed_chat_test.go
- ../sm-demo-app/plugins/demo/chatbots_test.go
- ../sm-demo-app/plugins/demo/classes/chat_context_test.go
- ../vue-chat-layer/app/lib/chatEvents.test.ts
- ../vue-chat-layer/app/lib/chatFormat.test.ts
- ../vue-chat-layer/app/lib/chatText.test.ts
- ../vue-chat-layer/app/lib/chatUnread.test.ts
- ../vue-chat-layer/app/lib/realtime.test.ts
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
- ../vue-chat-layer/app/lib/slots.ts
- ../vue-chat-layer/tests/client.test.ts
- ../vue-chat-layer/tests/store.test.ts
- ../vue-chat-layer/tests/support/http.ts
- ../vue-chatvideo-layer/tests/videoClient.test.ts
- ../vue-chatvideo-layer/tests/useCall.test.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
- ../sm-demo-app/vue-demo-app/app/components/SiteNav.vue
modified:
- ../sm-moderation-plugin/go.mod
- ../sm-moderation-plugin/README.md
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/reset_test.go
- ../sm-demo-app/scripts/check-chat.cjs
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/README.md
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
- ../vue-chat-layer/vitest.config.ts
- ../vue-chat-layer/app/components/Chat/Header.vue
- ../vue-chat-layer/README.md
- ../vue-chatvideo-layer/tests/callMachine.test.ts
- ../vue-chatvideo-layer/README.md
- ../vue-forum-layer/tests/client.test.ts
- ../vue-userfriends-layer/tests/client.test.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
- ../sm-demo-app/vue-demo-app/app/assets/css/site.css
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
key-decisions:
- "Task 6 checkpoint: push. The tests found no bugs, so there were no fixes, no tags and no fix release; only branch pushes."
- "Task 8 round 1: the user approved the chat (and the DM video call) on the live demo, and asked for Chat and Forum in the header with every feature link on the left and only the language switcher and Log out on the right"
- "Chat and Forum are header links in a local SiteNav, not seeded menu entries: MenuNav has no slot for app items, so the member links could not sit in its mobile panel, and seeded entries would have needed a new binary and demo:seed on rome. The fix shipped as a site-only redeploy."
- "Task 8 round 2: approved (the live header and the live check, including the DM video call)"
- "The DM-only conversation-actions rule became a tested helper (dmActionsTarget) instead of a component mount: @vue/test-utils is in no lockfile"
patterns-established:
- "check-all.sh covers every repo the demo's chat family depends on, with overridable *_PLUGIN_DIR and *_LAYER_DIR checkouts"
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-04, D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-13, D-14, D-15, D-17, D-18, D-20, D-21, D-22, D-23]
coverage:
- id: D1
description: "Banned words and links from the settings row reach the filter, the demo listeners and the chat and forum seams: 422 under content, title or body on every write path, nothing stored, list changes live, disabled passes"
requirement: "D-17, D-18"
verification:
- kind: unit
ref: "../sm-moderation-plugin/classes/filter_test.go#TestMatchWords, TestMatchLinks, TestParseList"
status: pass
- kind: integration
ref: "../sm-demo-app/moderation_test.go#TestDemoModeration"
status: pass
human_judgment: false
- id: D2
description: "The pre-save seams are silent without listeners and refuse totally with one (no row, no post-save event, no publication); a plain listener error stays 500"
requirement: "D-18"
verification:
- kind: integration
ref: "../sm-chat-plugin/saving_test.go#TestMessageSavingNoListener, TestMessageSavingRejects, TestMessageSavingOtherError"
status: pass
- kind: integration
ref: "../sm-forum-plugin/saving_test.go#TestPostSavingNoListener, TestPostSavingRejects"
status: pass
human_judgment: false
- id: D3
description: "Moderation settings limits, the filter cache and message locale, and the plugin's boot"
requirement: "D-17"
verification:
- kind: unit
ref: "../sm-moderation-plugin/models/settings_test.go#TestSettingsLimits; classes/filter_internal_test.go#TestFilterCheckCache, TestViolationMessage"
status: pass
- kind: integration
ref: "../sm-moderation-plugin/plugin_test.go#TestBootPublishesFilter, TestFilterCheckDisabled, TestFilterCheckReload"
status: pass
human_judgment: false
- id: D4
description: "Demo host: live membership, channel adapter, seeded-only channels, bootstrap JSON, members, DMs, moderator delete limited to channels, slash commands, DM video start against a fake RoomService, seed counts and idempotency, reset of every chat-family table, member-only realtime tokens, jwt.auth on every chat-family route"
requirement: "D-05, D-06, D-07, D-08, D-09, D-11, D-12, D-22, D-23"
verification:
- kind: integration
ref: "../sm-demo-app/chat_test.go, video_test.go#TestDemoVideoStart, seed_chat_test.go#TestSeedChat, reset_test.go#TestResetKeepsPlans, realtime_test.go#TestRealtimeMemberToken, boot_test.go#TestBootAllPlugins"
status: pass
- kind: unit
ref: "../sm-demo-app/plugins/demo/classes/chat_context_test.go, plugins/demo/chatbots_test.go#TestDemoCommands"
status: pass
human_judgment: false
- id: D5
description: "Layers: bm's chat tests unchanged on the port, the client, the multi-channel store, link embeds, the call machine, the video client, useCall on a mocked Room, the forum and friends clients, the site's auth hooks"
requirement: "D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23"
verification:
- kind: unit
ref: "pnpm test in vue-chat-layer (146), vue-chatvideo-layer (114), vue-forum-layer (12), vue-userfriends-layer (11), vue-demo-app (218)"
status: pass
human_judgment: false
- id: D6
description: "Two browsers locally: a live #general message with reaction and typing; a DM call with fake media, remote video on both sides, mute seen across, the session ended after both leave; no call button in #general"
requirement: "SC-2, D-11, D-13, D-23"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-local.sh chatlive callmedia (check-chat.cjs --two, --call)"
status: pass
human_judgment: false
- id: D7
description: "The phase gate, ending with check-live against the live demo"
requirement: "SC-2, SC-3, SC-4"
verification:
- kind: e2e
ref: "../sm-demo-app/scripts/check-all.sh --local (check-all: ok)"
status: pass
human_judgment: false
- id: D8
description: "Two-browser chat and a DM video call on https://demo.summercms.io, and the header with every link on the left"
requirement: "SC-2, SC-3"
verification:
- kind: manual
ref: "Task 8 UAT by the user: approved (after the header round)"
status: pass
human_judgment: true
duration: about 3 h of work (2026-11-01 17:42Z to the header redeploy), with two checkpoints and one UAT round
completed: 2026-11-02
status: complete
---
# Phase 20 Plan 06: unit tests for the chat family, two-browser checks and the live sign-off Summary
**The phase's new code is covered by tests in the repos that own it: the moderation filter and settings, both pre-save seams, the demo host (membership, channels, DMs, moderator delete, slash commands, video start against a fake LiveKit RoomService, seed, reset, realtime), bm's chat tests running unchanged on vue-chat-layer next to new client, store and link-embed tests, the call machine and useCall on a mocked Room, and the forum, friends and site auth clients. Two browsers prove live chat and a DM call with fake media locally, check-all gates every suite and ends green against the live demo, and the user approved the live chat, DM call and a reworked header.**
## Test counts
| Suite | Before | After |
|---|---|---|
| sm-moderation-plugin (Go top-level tests) | 2 | 16 |
| sm-chat-plugin (Go) | 44 | 47 |
| sm-forum-plugin (Go) | 16 | 18 |
| sm-demo-app `./...` (Go) | 27 | 47 |
| vue-chat-layer (vitest) | 6 | 146 |
| vue-chatvideo-layer (vitest) | 10 | 114 |
| vue-forum-layer (node --test) | 7 | 12 |
| vue-userfriends-layer (node --test) | 6 | 11 |
| vue-demo-app (vitest) | 209 | 218 |
Go counts are top-level test functions (`go test -list`); the Docker tests ran in every verify (PASS lines counted).
## Tests per repository
- **sm-moderation-plugin:**
- `classes`: `TestMatchWords`, `TestMatchWordsFirstAndEmpty`, `TestMatchLinks`, `TestMatchLinksInvalidEntries`, `TestParseList`, `TestFilterCheckCache` and `TestViolationMessage`.
- `models`: `TestSettingsLimits`, `TestLinkEntry` and `TestSettingsModel`.
- root (testcontainers): `TestBootPublishesFilter`, `TestFilterCheckDisabled` and `TestFilterCheckReload`.
- **sm-chat-plugin:** `TestMessageSavingNoListener`, `TestMessageSavingRejects` (send, reply, ReplyTo, edit; field empty, content or custom) and `TestMessageSavingOtherError`.
- **sm-forum-plugin:** `TestPostSavingNoListener` and `TestPostSavingRejects` (thread, reply, edit; title, body or empty field; plain error 500).
- **sm-demo-app:**
- `TestDemoModeration`.
- In chat_test.go: `TestDemoChatContextMembership`, `TestDemoChannelAdapter`, `TestDemoChannelsSeededOnly`, `TestDemoChatBootstrap`, `TestDemoChatMembers`, `TestDemoDMs`, `TestDemoModeratorDelete` and `TestDemoCommands` (HTTP).
- `TestDemoVideoStart`, `TestSeedChat` and `TestSeedChatWithoutVisitor`.
- Extended `TestResetKeepsPlans` and `TestBootAllPlugins`, and the new `TestRealtimeMemberToken`.
- In `plugins/demo/classes`: `TestDemoChatContextConstants`, `TestDemoChatContextMembershipHooks`, `TestDemoChatContextLoaders`, `TestDemoChatAccess`, `TestDemoForumAccess` and `TestDemoMembershipWithoutDatabase`.
- In `plugins/demo`: `TestDemoCommands`.
- **vue-chat-layer:**
- bm's `chatEvents`, `chatFormat`, `chatText`, `chatUnread` and `realtime` tests next to their ports (only the imports differ).
- `linkEmbeds.test.ts`, `tests/client.test.ts` and `tests/store.test.ts`.
- **vue-chatvideo-layer:** `callMachine.test.ts` (every event in every state), `videoClient.test.ts` and `useCall.test.ts` (`vi.mock('livekit-client')`).
- **vue-forum-layer, vue-userfriends-layer:** every client method's refusal and answer, and the error bodies.
- **vue-demo-app:** `app/utils/communityAuth.test.ts`. The existing `app/lib/realtime.test.ts` still passes through the re-export.
**What TestDemoVideoStart recorded for a channel start.** Seeded #general has 5 participants (4 personas plus the caller) against `max_participants` 4. A start there answers 422 "Conversation has too many participants for a video call". With 2 participants it answers 201, so the server has no conversation-type rule; calls are limited to DMs only by the UI (D-23). The DM start answers 201, then 200 with the same session on a second start and for the other side. The token claims (issuer, user id, room) verify with the test secret, and CreateRoom reached the fake Twirp RoomService exactly once with a bearer token.
## Task commits
| Task | Repo | Commit | Message |
|---|---|---|---|
| 1 | sm-moderation-plugin | 8a93cc8 | test: word, phrase and link matching of the filter |
| 1 | sm-demo-app | a660add | test: moderation end to end through the chat and forum seams |
| 2 | sm-moderation-plugin | 313d117 | test: settings limits, filter cache and locale, boot publishes the filter |
| 2 | sm-chat-plugin | 6539dd2 | test: MessageSaving parity, refusals on every write path and other errors |
| 2 | sm-forum-plugin | 13aa642 | test: PostSaving parity and refusals on thread, reply and edit |
| 3 | sm-demo-app | 08b7784 | test: demo chat family |
| 4 | vue-chat-layer | 764cb42 | refactor: the DM-only conversation-actions rule as a helper |
| 4 | vue-chat-layer | b6d3389 | test: the SPA's chat tests on the port, client, store and link embeds |
| 4 | vue-chatvideo-layer | 8222255 | test: every call transition, the video client and useCall on a fake Room |
| 4 | vue-forum-layer | 4780824 | test: every forum client method's refusal and answer, error bodies |
| 4 | vue-userfriends-layer | 6a5f6ce | test: every friends client method's refusal and answer, error bodies |
| 4 | vue-demo-app | 0c1a66b | refactor: community auth hooks into app/utils |
| 4 | vue-demo-app | ec45480 | test: community auth hooks |
| 5 | sm-demo-app | 9b9da38 | test: two-browser chat and call checks; phase gate covers the chat family |
| 5 | sm-demo-app | 3d86215 | chore: bump vue-demo-app (community auth hooks and their tests) |
| 5 | sm-demo-app | 71ff755 | chore: sync the roadmap snapshot |
| 6 | — | — | checkpoint: **push** |
| 7 | all nine | — | branch pushes; check-all: ok |
| 8 | vue-demo-app | deb809f | feat: every site link on the left of the header, with Chat and Forum |
| 8 | sm-demo-app | 2cd4640 | chore: bump vue-demo-app (header: all links left, Chat and Forum) |
| 8 | — | — | site-only redeploy on rome; UAT: **approved** |
Every repo was pushed with plain fast-forwards. `git ls-remote origin` equals local HEAD for each:
| Repo | Pushed head |
|---|---|
| sm-moderation-plugin | 313d117 |
| sm-chat-plugin | 6539dd2 |
| sm-forum-plugin | 13aa642 |
| vue-chat-layer | b6d3389 |
| vue-chatvideo-layer | 8222255 |
| vue-forum-layer | 4780824 |
| vue-userfriends-layer | 6a5f6ce |
| vue-demo-app | deb809f |
| sm-demo-app | 2cd4640 |
No tags were created in this plan.
## Checkpoint answers and the UAT
- **Task 6:** push. No bugs were found, so there were no fixes and no fix release; only branch pushes.
- **Task 7:** `check-all.sh --local` printed `check-all: ok`. It covered:
- every suite: the app, the MCP-tagged app, sm-user, sm-journal, chat, forum and moderation with Docker, `-short` of chatchannels, chatbots, chatvideo, herald and userfriends, vitest in vue-demo-app and the four layers;
- `check-pins: ok (25 modules)`, `build: ok release e50834496975`, `smoke: ok` and `check-deploy: ok`;
- `check-local: ok back remote chatlive callmedia`;
- `check-live: ok` against https://demo.summercms.io.
- **Task 8, round 1:** "chat itself approved, but it's missing in navbar … make all to the left, and on the right just language and logout."
- Fix: `SiteNav` renders the main menu's entries (vue-menu-layer's `MenuNavItem`), then Chat and Forum for everyone, then My presentations, Friends and Security for members, in one list. The right side keeps only the language switcher and Log in or Log out.
- Below 1180px every link is in the menu panel, so the member links are reachable on phones too. Phone spacing was tightened to fit 390px.
- Checked with Playwright at 1280, 1440, 1024 and 390px, as visitor and member, in EN and PL. Also `check-local tour chat back`, `smoke.sh`, vue-demo-app's tests and build.
- Released site-only after the user's "yes":
- pushed deb809f and 2cd4640;
- `build: ok release f9fa556fd20e`, `smoke: ok`;
- rsynced only `vue-demo-app/.output/` to `site/.output.new`, swapped it in (`.output.prev` kept) and restarted only `summercms-demo-site` (RUNNING, pid 1773325);
- `check-live: ok`;
- the anonymous live header shows Home, Journal, FAQ, Subscriptions, Chat, Forum (PL: Start, Blog, FAQ, Subskrypcje, Czat, Forum) on the left and EN/PL and Log in on the right.
- The binary, config, migrations, `demo:seed`, nginx, Centrifugo and LiveKit were untouched. Site rollback: `sudo mv site/.output site/.output.bad && sudo mv site/.output.prev site/.output && sudo supervisorctl restart summercms-demo-site`.
- **Task 8, round 2:** approved. This covers the live header and the live check, including the two-browser chat and the DM video call.
## Deviations from Plan
1. **Slot helper instead of a component mount.** @vue/test-utils is in no lockfile, so ChatHeader's inline DM-only `v-if` became `dmActionsTarget` (`vue-chat-layer/app/lib/slots.ts`, 764cb42, no behaviour change) and is tested in the store test. It is on vue-chat-layer main, untagged; the demo still pins v0.1.0.
2. **Where the filter tests landed.** `TestFilterCheckCache` is an internal test in `classes`. The database tests (`TestFilterCheckDisabled`, `TestFilterCheckReload`) are in the plugin root's `plugin_test.go` with the sitemap-style harness, not in `filter_test.go`. Extra tests were added (listed above).
3. **Test-only dependencies.** sm-moderation-plugin's go.mod gained testcontainers-go v0.44.0 and pgx v5.10.0, resolved offline with `GOPROXY=off` from the versions the other plugins already pin. The README Testing section names them.
4. **The ported realtime test.** vue-chat-layer's `realtime.test.ts` is vue-demo-app's adapted copy of bm's test (a stubbed token route). The vitest config maps bm's `@/api/http` to `tests/support/http.ts`. The other four bm tests match bm's after `describe` (diff empty).
5. **Split chatvideo test file.** The video-client part of `callMachine.test.ts` moved into a new `videoClient.test.ts`.
6. **Extra demo tests.** `TestSeedChatWithoutVisitor`; `TestDemoCommands` exists in `plugins/demo` (the command objects) and in the root package (HTTP); `TestBootAllPlugins` checks `jwt.auth` on every chat-family route.
7. **Commits needed by the gate.** The vue-demo-app pointer bump (3d86215) and a roadmap snapshot sync (71ff755) were committed in Task 5. `build.sh release` refuses a dirty tree and a stale snapshot, and the framework's ROADMAP had moved since plan 05.
8. **The check-back hook.** The header rework kept the `site-header__mine` class on the member links and keeps the text out of template comments. scripts/check-back.cjs (also run by check-live) relies on it, so the live check works on the old and the new site alike.
9. **Header breakpoint.** The panel takes over below 1180px rather than only below 768px, because nine links for a member do not fit on one row below that width.
**Total deviations:** 9. **Impact on plan:** none on the goal. No production behaviour changed except the header, which the user asked for in the UAT.
## Notes
- **Banned-link prefix matching.** A banned-link entry with a path matches by string prefix, so `shop.example/promo` also matches `/promotions`. That's what the README and the admin form say ("starts with"); the plan said "only below its path". It is not pinned by a test.
- **A stale snapshot reddens the gate.** Any later commit to the framework ROADMAP turns `check-all` red at `sync-roadmap --check` until the demo's snapshot is synced and committed.
- **Leftover dev process.** A `nuxt dev` from Oct 22 (pids 212159 and 212194) still runs in vue-demo-app, listening on no port. It was left alone, as instructed.
- **Phone header before this plan.** At 390px the visitor's Polish header ("Zaloguj się") probably overflowed already; the header round fixed it along the way.
## Known Stubs
None.
## Threat Flags
None. The new surface is test code and check scripts. The scripts read the visitor's credentials only through check-local's existing `.env` helper and never print them; the header change adds links to existing pages.
## Self-Check: PASSED
- Commits exist on their remotes: sm-moderation-plugin 8a93cc8 and 313d117; sm-chat-plugin 6539dd2; sm-forum-plugin 13aa642; vue-chat-layer 764cb42 and b6d3389; vue-chatvideo-layer 8222255; vue-forum-layer 4780824; vue-userfriends-layer 6a5f6ce; vue-demo-app 0c1a66b, ec45480 and deb809f; sm-demo-app a660add, 08b7784, 9b9da38, 3d86215, 71ff755 and 2cd4640 (`ls-remote` heads above).
- The key files listed above exist in their repos.
- rome: summercms-demo-site RUNNING with `site/.output` (new) and `site/.output.prev`; check-live ok.

View File

@@ -0,0 +1,255 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 07
type: execute
wave: 7
depends_on: ["20-06"]
gap_closure: true
review_findings: [CR-02, CR-03]
files_modified:
- ../sm-moderation-plugin/classes/filter.go
- ../sm-moderation-plugin/classes/normalize.go
- ../sm-moderation-plugin/classes/html.go
- ../sm-moderation-plugin/classes/bypass_test.go
- ../sm-moderation-plugin/classes/html_test.go
- ../sm-moderation-plugin/classes/filter_test.go
- ../sm-moderation-plugin/models/settings.go
- ../sm-moderation-plugin/models/settings_test.go
- ../sm-moderation-plugin/plugin_test.go
- ../sm-moderation-plugin/go.mod
- ../sm-moderation-plugin/go.sum
- ../sm-moderation-plugin/README.md
- ../sm-forum-plugin/events/events.go
- ../sm-forum-plugin/classes/forum_service.go
- ../sm-forum-plugin/classes/lifecycle.go
- ../sm-forum-plugin/saving_test.go
- ../sm-forum-plugin/README.md
autonomous: true
requirements: [D-17, D-18, SC-2]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "CR-03, D-17: classes.Match and Filter.Check refuse every word bypass the reviewer listed: a zero-width space or soft hyphen inside a banned word (and every other format character), a no-break, thin or ideographic space between the words of a banned phrase, decomposed (NFD) Polish diacritics against an NFC entry, fullwidth letters, HTML entities (vi&#97;gra) and inline markup inside a word (via**gra**, via`gra`, _viagra_). Matching is diacritic-insensitive (zolw matches the entry żółw) and reports the entry as listed."
- "CR-02, D-17: classes.Match and Filter.Check refuse every link bypass the reviewer listed: a percent-encoded host (bit%2Ely, bit.l%79), fullwidth letters and ideographic, fullwidth or halfwidth dots in the host, a port in front of a path rule (example.com:443/promo), a percent-encoded or doubled path (/%70romo, //promo), a trailing dot (example.com./promo), a subdomain of a path rule (sub.example.com/promo), userinfo (user@bit.ly), an entity in a link destination ([x](https://bit&#46;ly/a)), and either spelling of an IDN (punycode against Unicode, both ways). The http(s) scheme with no or mixed slashes and backslashes after the colon (https:bit.ly) counts as the host it opens."
- "Clean text stays clean: every existing case of TestMatchWords, TestMatchWordsFirstAndEmpty, TestMatchLinks, TestMatchLinksInvalidEntries and TestParseList keeps its expectation, except `a.shop.example/promo`, which the review's subdomain rule now flags."
- "CR-03, D-18: forum's events.PostSaving carries HTML, the content_html breakwater renders from Body, on thread, reply and edit; the value equals what is stored. With no listener the forum's routes, payloads, stored rows and status codes are unchanged, and the existing seam, golden and method-map tests pass unedited."
- "CR-03: classes.MatchHTML and Filter.CheckHTML read rendered HTML as a member sees it (text across inline tags joined, entities decoded, href and src values as links), so the forum's Markdown forms the raw check cannot see (bit\\.ly, via<!-- x -->gra, [x](https:bit.ly), a reference link with an entity in its destination) are refused once a host checks PostSaving.HTML."
- "No new module enters the build: golang.org/x/text v0.42.0 and golang.org/x/net v0.58.0 become direct requires of sm-moderation-plugin at the versions the framework already requires directly (summercms.go go.mod), resolved offline."
artifacts:
- path: "../sm-moderation-plugin/classes/normalize.go"
provides: "text normaliser (entities, NFKC, format characters, Unicode spaces, diacritics, inline markup) and the link-candidate canonicaliser (percent-decoding, dots, IDNA, port, userinfo, path)"
contains: "norm.NFKC"
- path: "../sm-moderation-plugin/classes/html.go"
provides: "MatchHTML and Filter.CheckHTML over x/net/html tokens"
contains: "func MatchHTML"
- path: "../sm-moderation-plugin/classes/bypass_test.go"
provides: "the reviewer's probes as regression tests"
contains: "TestMatchBypassLinks"
- path: "../sm-forum-plugin/events/events.go"
provides: "PostSaving.HTML"
contains: "HTML"
key_links:
- from: "../sm-forum-plugin/classes/forum_service.go"
to: "../sm-forum-plugin/events/events.go"
via: "CreateThread and Reply render first and fire PostSaving with HTML set to the rendered body"
pattern: "HTML: *html"
- from: "../sm-forum-plugin/classes/lifecycle.go"
to: "../sm-forum-plugin/events/events.go"
via: "EditPost renders first and fires PostSaving with HTML"
pattern: "HTML: *html"
- from: "../sm-moderation-plugin/classes/filter.go"
to: "../sm-moderation-plugin/classes/normalize.go"
via: "compile folds every entry and match runs on the normalised text, Check and Match alike"
pattern: "normali[sz]e"
prohibitions:
- statement: "sm-moderation-plugin imports neither sm-chat-plugin nor sm-forum-plugin."
status: unresolved
verification: automated
- statement: "No push, tag or deploy in this plan; sm-demo-app is not edited here (its listener change needs the new tags and lands in 20-09)."
status: unresolved
verification: judgment
- statement: "Forum's HTTP API, stored columns, migrations and config keys do not change; PostSaving gains a field and nothing else changes shape."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public site shows them working, deployed publicly.
This plan's slice (gap round after the code review, plan 7 of 9, wave 7): the moderation filter refuses what members actually see, not only the literal text they type. It closes CR-02 (banned-link bypass through URL forms browsers normalise) and CR-03 (banned-word bypass through invisible characters, Unicode spaces, decomposed diacritics and forum Markdown) from 20-REVIEW.md, in the two repositories that own the code: sm-moderation-plugin (the filter) and sm-forum-plugin (the pre-save seam passes the rendered post).
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix in this plan ships with its own regression tests in the repository that owns the code, and plan 20-09 runs every touched repository's full suite before anything is tagged.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Where the rendered text comes from.** Forum renders the body before it fires PostSaving and passes the result in a new field `HTML` (additive; the review's second option, "so every host gets it"). The moderation plugin offers `MatchHTML` and `Filter.CheckHTML` to read it. Moderation stays free of chat and forum imports; HTML is generic, so a later host (journal posts, a rich editor) can use the same call.
- **The raw check stays and gets stronger.** `Check` and `Match` normalise the text themselves (entities, NFKC, format characters, Unicode spaces, diacritics, inline markup inside words), so chat messages, which the browser renders with vue-chat-layer's inline renderer, and forum titles are covered without any host change. The three forum forms the reviewer reproduced (via**gra**, vi&#97;gra, an entity in a link destination) are already refused on the raw body by this normalisation; `CheckHTML` adds the forms only the renderer resolves.
- **The demo listener.** `plugins/demo/moderation.go` `checkMessage` needs no change (Check normalises). `checkPost` needs one more check, `CheckHTML` on `ev.HTML`, which compiles only against the new moderation and forum tags. sm-demo-app's go.work points at the submodule checkouts pinned to the current tags, so that edit is made in 20-09 right after the dependency bumps, keeping every sm-demo-app commit buildable. This plan records the finding in its SUMMARY.
- **Dependencies (CLAUDE.md rule).** `golang.org/x/text` (unicode/norm) and `golang.org/x/net` (idna for punycode, html for the tokenizer) move from indirect to direct requires of sm-moderation-plugin. Both are already direct requires of the framework at v0.42.0 and v0.58.0 (summercms.go go.mod), already in the plugin's go.sum and in the module cache, and sm-journal-plugin imports `x/net/html` directly (.planning/notes/journal-html-sanitizing-bluemonday.md). The standard library has no NFKC, IDNA or HTML tokenizer. This plan is the phase note that names them; the README's Dependencies section says so too.
- **Diacritic-insensitive matching** folds both the entries and the text: NFD, drop nonspacing marks, recompose, plus a small map for the letters that have no decomposition (ł, đ, ø, ħ, ŧ and their capitals). An entry is reported as listed, so `Match` keeps answering "żółw", not its folded form.
- **Path rules match subdomains** as host rules do (the review's fix). This flips one existing expectation, `a.shop.example/promo`, from clean to flagged; the plan updates that case and no other.
- **Versions.** The next patch tags are v0.1.1 for sm-moderation-plugin (its latest tag is v0.1.0; the brief's v0.1.2 assumed a v0.1.1 that does not exist) and v0.1.2 for sm-forum-plugin (latest v0.1.1). The README changelog entries name those versions; 20-09 re-checks the tags before the release gate.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
<objective>
Make the banned-word and banned-link filter match what a member sees: normalise the text and every link candidate before matching, read rendered forum HTML, and give the forum seam the rendered post.
Purpose: D-17 (banned words and links refused in chat and forum) holds against anyone who tries more than once; D-18's seam carries what the forum stores. Closes CR-02 and CR-03 server-side.
Output: commits in ../sm-moderation-plugin and ../sm-forum-plugin (not pushed, not tagged), with regression tests for every bypass the reviewer listed.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
Repos: ../sm-moderation-plugin (main at 313d117, tag v0.1.0) and ../sm-forum-plugin (main at 13aa642, tag v0.1.1). Both have a tracked go.work (`use .`, framework replaced by ../summercms.go). Read-only reference: ../sm-demo-app/plugins/demo/moderation.go (the listener), ../summercms.go/modules/breakwater (Render), ../sm-demo-app/plugins/demo/console/seed_friends.go (the demo's default lists: buy followers, casino bonus, free crypto, viagra; bit.ly).
Rules: no co-author tags; stage files by explicit path; plugin code and planning docs in separate commits; no push or tag (20-09 does both after a go-ahead); framework code and these plugins name no consuming application (README examples use `acme`); check `git status` in each repo before editing and leave edits that are not this plan's alone. Docker tests skip under `-short`; if the Docker socket is denied, run the Docker tests through `sudo -u golem` (CLAUDE.md). Resolve modules offline: `GOFLAGS=-mod=mod GOPROXY=off` for `go mod tidy`.
The reviewer's probe: /tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/0fc9bf8a-bc2a-4cab-8faa-51763a879541/scratchpad/modtest/main_test.go (a module that replaces sm-moderation-plugin with the sibling checkout; run it with `GOWORK=off GOFLAGS=-mod=mod GOPROXY=off go test -v` in that directory). It may be gone after a reboot; its cases are copied into bypass_test.go in Task 1 and Task 2, which are the record.
<interfaces>
sm-moderation-plugin today (classes/filter.go): consts KindWord, KindLink; type Violation {Kind, Match, Message}; `boundary` = non-letter, non-digit, non-underscore; `hostPattern` regexp (labels of letters, digits, inner hyphens, a dot; optional http(s) scheme; optional path); `ParseList(text) []string` (trim, lower-case, collapse whitespace, dedupe); unexported `compile(words, links) (*matcher, error)` and `(*matcher).match(text) *Violation` (words regexp `(?i)(?:^|boundary)(alt|alt)(?:$|boundary)` with `\s+` between phrase words; link rules {entry, host, path}, host rules match the host and its subdomains, path rules the same host and a path prefix); `Match(words, links, text) *Violation` (pure); `Filter` {db, tr, cache} with `NewFilter(db)`, `SetTranslator(tr)`, `Check(ctx, text) (*Violation, error)` (reads row 1, enabled switch, recompiles on change, translated message).
models/settings.go: `LinkEntry(entry) (host, path string, ok bool)` (host entry or http(s) URL, lower-cased, www. and trailing dot dropped, path unescaped, port and userinfo refused), `ValidHost(h)`, `Lines`, `Text`, `Settings.BeforeValidate` (uses LinkEntry to refuse bad link entries).
sm-forum-plugin: events.PostSaving {ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}; fire sites CreateThread (classes/forum_service.go, Fire at ~284, Render at ~290), Reply (~341, Render ~347), EditPost (classes/lifecycle.go ~235, Render ~241), each guarded by `s.bus != nil`; tests saving_test.go TestPostSavingNoListener, TestPostSavingRejects (Docker harness).
breakwater.Render output for the forum probes (checked during planning): via**gra** → `<p>via<strong>gra</strong></p>`; vi&#97;gra → `<p>viagra</p>`; [x](https://bit&#46;ly/a) → an anchor with href https://bit.ly/a; bit\.ly → `<p>bit.ly</p>`; via<!-- -->gra → `<p>viagra</p>`; [x](https:bit.ly) → href "https:bit.ly"; _viagra_ → `<em>viagra</em>`; a reference link `[x][1]` with `[1]: https://bit&#46;ly/r` → href https://bit.ly/r.
Go facts (checked during planning): net/url refuses a percent-escape in the host ("invalid URL escape %2E"), so the authority is percent-decoded before parsing; `idna.Lookup.ToASCII` maps fullwidth letters and the dots U+3002, U+FF0E and U+FF61 and gives punycode for an IDN (żółw.pl → xn--w-uga1v8h.pl); NFKC maps fullwidth letters and U+FF0E but not U+3002.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a banned word split by a zero-width space and a banned host written as bit%2Ely are refused by Match and by Filter.Check, through one normaliser and one link canonicaliser, under test</name>
<precondition>`git -C ../sm-moderation-plugin status --porcelain` prints nothing and `git -C ../sm-moderation-plugin rev-parse HEAD` equals `git -C ../sm-moderation-plugin ls-remote origin refs/heads/main` (fast-forward base).</precondition>
<files>../sm-moderation-plugin/classes/normalize.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/bypass_test.go, ../sm-moderation-plugin/plugin_test.go, ../sm-moderation-plugin/go.mod, ../sm-moderation-plugin/go.sum</files>
<read_first>../sm-moderation-plugin/classes/filter.go (whole file), classes/filter_test.go (runMatch helper and the existing cases), classes/filter_internal_test.go, plugin_test.go (TestMain, the testcontainers harness, TestFilterCheckDisabled), models/settings.go (LinkEntry), go.mod, 20-REVIEW.md CR-02 and CR-03, the scratchpad probe main_test.go</read_first>
<action>Per D-17, closing the first case of CR-03 and of CR-02 end to end.
1. Red first: create classes/bypass_test.go (package classes_test, reusing the runMatch shape of filter_test.go) with `TestMatchBypassWords` holding one case, the word "viagra" with a U+200B zero-width space after "via", and `TestMatchBypassLinks` holding one case, "https://bit%2Ely/x" against the host entry bit.ly. Lists come from `classes.ParseList` of the demo defaults (words "buy followers", "casino bonus", "free crypto", "viagra"; links "bit.ly" and "example.com/promo"). Run them and see both fail.
2. classes/normalize.go (new, unexported helpers, doc comments in the file's style):
- a text normaliser used by both Match and Filter.Check: decode HTML entities once with the standard library's `html.UnescapeString`, apply `norm.NFKC` (golang.org/x/text/unicode/norm), drop every rune of category Cf (zero-width space, joiners, word joiner, BOM, soft hyphen, bidi controls), and turn every rune that `unicode.IsSpace` or the Z categories accept into a plain space;
- a link-candidate canonicaliser: given one candidate (with or without an http(s) scheme), percent-decode its authority once (the WHATWG parser does; net/url refuses an escape in the host), parse it with net/url, take `Hostname()` (this drops the port and userinfo), lower-case it, drop a trailing dot and a leading "www.", and convert it with `idna.Lookup.ToASCII` (golang.org/x/net/idna), falling back to the lower-cased NFKC form when ToASCII refuses the label.
3. classes/filter.go: `compile` folds each word entry with the same normaliser before quoting it, and remembers the entry as listed; `match` normalises the text first; link candidates found in the text go through the canonicaliser, and rule hosts are converted to their ASCII form once in compile. Keep `Violation.Match` the entry as listed (ParseList's form). This task only needs candidate extraction to see "bit%2Ely" as one host: widen candidate extraction so a host may contain percent-escapes (Task 2 replaces it with full tokenising).
4. go.mod: `GOFLAGS=-mod=mod GOPROXY=off go -C ../sm-moderation-plugin mod tidy`; golang.org/x/text and golang.org/x/net move to the direct block at v0.42.0 and v0.58.0 (no version change, no new module).
5. plugin_test.go: add `TestFilterCheckNormalises` (Docker, the existing harness): with the settings row holding the demo default lists, `Filter.Check` on the zero-width-space word answers KindWord "viagra" and on "https://bit%2Ely/x" KindLink "bit.ly", and clean text answers nil.
6. Make both bypass tests and every existing test green. Commit in sm-moderation-plugin: `fix: normalise text and link hosts before matching banned entries`.</action>
<verify>
<automated>go -C ../sm-moderation-plugin vet ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -short -run '^(TestMatchBypassWords|TestMatchBypassLinks)$' -v ./classes 2>&amp;1 | grep -c -- '^--- PASS: TestMatchBypass' | grep -qx 2 &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterCheckNormalises$' .</automated>
<fails_when>Non-zero exit: vet fails, an existing short test breaks, either bypass test is missing or red, or the Docker test TestFilterCheckNormalises fails (the stored lists do not reach the normalised matcher).</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE '^\s+golang.org/x/(text|net) v0\.(42|58)\.0$' ../sm-moderation-plugin/go.mod` prints 2 (both direct, without the indirect comment).
- `go -C ../sm-moderation-plugin list -deps ./... | grep -cE 'sm-(chat|forum)-plugin'` prints 0.
</acceptance_criteria>
<done>One invisible-character word and one percent-encoded host are refused by the pure matcher and by the stored-list filter, and the tracer's normaliser and canonicaliser are the single path every later case goes through.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Every word and link bypass the reviewer listed is refused, entries are normalised the same way, and clean text stays clean</name>
<files>../sm-moderation-plugin/classes/normalize.go, ../sm-moderation-plugin/classes/filter.go, ../sm-moderation-plugin/classes/bypass_test.go, ../sm-moderation-plugin/classes/filter_test.go, ../sm-moderation-plugin/models/settings.go, ../sm-moderation-plugin/models/settings_test.go, ../sm-moderation-plugin/README.md</files>
<read_first>The Task 1 code, classes/filter_test.go (all cases), models/settings_test.go (TestLinkEntry), README.md (Features, Usage, API reference, Dependencies, Testing, Changelog), 20-REVIEW.md CR-02 table and CR-03 list, ../vue-chat-layer/app/lib/markdown.ts formatSpans and ../vue-chat-layer/app/lib/chatText.ts CODE_RE (the inline markup chat renders)</read_first>
<behavior>
- Words (TestMatchBypassWords): viagra split by U+200B, U+00AD, U+200D, U+2060 or U+FEFF; "buy followers" with U+00A0, U+2009, U+202F or U+3000 between the words; fullwidth viagra; vi&#97;gra and vi&#x61;gra; via**gra**, via`gra`, via~~gra~~ and _viagra_; with the entry żółw, its NFD spelling (z plus U+0307, o plus U+0301, ł, w) and the folded spelling zolw both flag and report "żółw".
- Words that must stay clean: spam_bot, _spam, spamming (with spam listed), and every existing TestMatchWords case.
- Links (TestMatchBypassLinks), against bit.ly and example.com/promo: https://bit%2Ely/x, https://bit.l%79/x, bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, https://example.com@bit.ly/, [x](https://bit&#46;ly/a), https:bit.ly, https:/\bit.ly/x, https://bit.ly./x, https://example.com:443/promo, https://example.com/%70romo, https://example.com//promo, https://example.com/./promo, https://example.com./promo, https://sub.example.com/promo; with the entry żółw.example, https://xn--w-uga1v8h.example/ flags, and with the entry xn--w-uga1v8h.example, https://żółw.example/ flags.
- Links that must stay clean: https://notbit.ly/x, https://example.com/about, https://example.com/x/promo, https://bit.lyx/, and every existing TestMatchLinks case except a.shop.example/promo, which now flags (path rules cover subdomains).
</behavior>
<action>Per D-17, closing CR-02 and the text half of CR-03.
- Extend bypass_test.go with every case of the behavior block (one table per test, each case commented with the bypass it pins), then make it green:
- normaliser: after the Task 1 steps, fold diacritics (NFD, drop category Mn, recompose with NFC, then map ł Ł đ Đ ø Ø ħ Ħ ŧ Ŧ to their base letters); the same function folds the entries in compile, and a map from folded entry to listed entry keeps `Violation.Match` as listed. Join the words of a phrase with `[\p{Z}\s]+`.
- markup: `match` runs the words and links on two forms of the text, first the normalised text, then the same text with inline markup removed: every asterisk, backtick and tilde dropped, and an underscore pair around a word (`_word_` between boundaries, the rule of vue-chat-layer's formatSpans) unwrapped. A lone underscore stays a word character, so spam_bot and _spam stay clean.
- links: replace the regexp candidate extraction with tokenising: split the normalised text on whitespace and on the delimiters `<>"'()[]{}`, trim trailing sentence punctuation (.,;:!?), and treat a token as a candidate when it has an http(s) scheme or contains a dot after the dot mapping. For http and https, any run of slashes and backslashes after the colon (none included) counts as `//`, as the WHATWG parser reads special schemes. The candidate's path is percent-decoded and cleaned with path.Clean after a leading slash (collapses //, /./ and dot segments) and compared lower-cased. A path rule matches its host and every subdomain, like a host rule.
- models/settings.go `LinkEntry`: apply NFKC and the dot mapping to the entry, and return the host in its IDNA ASCII form (`idna.Lookup.ToASCII`); an entry whose host ToASCII refuses is not ok, so the admin form refuses it. Existing ASCII cases of TestLinkEntry keep their results; change the żółw.example case to expect its punycode host and add cases for a fullwidth entry and an entry with U+3002.
- filter_test.go: change only the a.shop.example/promo case to expect a KindLink violation for https://shop.example/promo, with a comment that path rules cover subdomains (CR-02).
- README.md: Features (normalisation and link canonicalisation, in plain words, with the list of forms), Usage unchanged, API reference (LinkEntry's ASCII host), Dependencies (golang.org/x/text and golang.org/x/net, why, at the framework's versions), Testing (the new test names), Changelog `### v0.1.1` (banned entries match what members see: invisible characters, Unicode spaces, diacritics, entities, inline markup, URL forms browsers normalise; path rules cover subdomains; no schema, config or API change other than LinkEntry's ASCII host). Name no application.
- Commit in sm-moderation-plugin: `fix: match banned words and links in the forms browsers show`.</action>
<verify>
<automated>go -C ../sm-moderation-plugin vet ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 ./... &amp;&amp; P=/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/0fc9bf8a-bc2a-4cab-8faa-51763a879541/scratchpad/modtest; if [ -f "$P/main_test.go" ]; then (cd "$P" &amp;&amp; GOWORK=off GOFLAGS=-mod=mod GOPROXY=off go test -count=1 -v . 2>&amp;1 | grep -c 'flagged=false' | grep -qx 0); fi</automated>
<fails_when>Non-zero exit: vet fails; a short or Docker test fails (a bypass case still passes, a clean case is now flagged, an existing expectation other than a.shop.example/promo changed, LinkEntry refuses a valid entry); or the reviewer's probe, when it still exists, prints any flagged=false line.</fails_when>
</verify>
<acceptance_criteria>
- `go -C ../sm-moderation-plugin test -count=1 -short -run '^TestMatchBypass(Words|Links)$' -v ./classes 2>&amp;1 | grep -c -- '^--- PASS'` prints 2.
- `grep -F 'a.shop.example/promo' ../sm-moderation-plugin/classes/filter_test.go | grep -c 'KindLink'` prints 1 (the one existing case that changed), and `git -C ../sm-moderation-plugin diff --stat 313d117 -- classes/filter_test.go` reports 1 deletion (the old case line; the insertions are the new case and its comment).
- `grep -c '### v0.1.1' ../sm-moderation-plugin/README.md` prints 1.
</acceptance_criteria>
<done>No word or link form from the review gets past the filter, an admin's entry matches in every spelling a browser accepts, and every clean sentence the earlier tests pinned still passes.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: The forum seam carries the rendered post, and the filter reads rendered HTML the way a member sees it</name>
<files>../sm-moderation-plugin/classes/html.go, ../sm-moderation-plugin/classes/html_test.go, ../sm-moderation-plugin/README.md, ../sm-forum-plugin/events/events.go, ../sm-forum-plugin/classes/forum_service.go, ../sm-forum-plugin/classes/lifecycle.go, ../sm-forum-plugin/saving_test.go, ../sm-forum-plugin/README.md</files>
<read_first>../sm-forum-plugin/events/events.go, classes/forum_service.go (CreateThread, Reply), classes/lifecycle.go (EditPost, Preview), saving_test.go (harness, TestPostSavingNoListener, TestPostSavingRejects), README.md (Features "Pre-save seam", API reference, Testing, Changelog), ../summercms.go/modules/breakwater/breakwater.go (Render, the sanitiser policy: which tags and attributes survive), ../sm-demo-app/plugins/demo/moderation.go (checkPost, read only), the Task 2 normaliser</read_first>
<behavior>
- TestMatchHTML (moderation, -short): for each forum probe, MatchHTML(defaults, breakwater.Render(probe)) is a violation: via**gra**, vi&#97;gra, _viagra_, via`gra`, via<!-- x -->gra (word "viagra"); [x](https://bit&#46;ly/a), [x](https:bit.ly), bit\.ly, a reference link [x][1] with [1]: https://bit&#46;ly/r, and an autolink <https://bit.ly/x> (link "bit.ly"). Clean Markdown (a heading, a list, a link to https://example.org/about, code with "via" in one span and "gra" in another paragraph) is not.
- Text across inline tags joins without a boundary (via<strong>gra</strong> is "viagra"); block tags and <br> separate (<p>via</p><p>gra</p> is clean); href and src values are link candidates; alt and title text is checked as words.
- TestPostSavingHTML (forum, Docker): a recording listener sees, for a thread, a reply and an edit, HTML equal to the content_html stored for that post; a body of vi&#97;gra gives HTML that contains "viagra"; TestPostSavingNoListener and TestPostSavingRejects pass unchanged.
</behavior>
<action>Per D-17 and D-18, closing the forum half of CR-03.
sm-forum-plugin:
- events/events.go: add the field `HTML` to PostSaving, documented as the content_html that would be stored, rendered by breakwater from Body (sanitised), set on every Op. Update the package and type comments: the event fires after the Markdown is rendered and before anything is persisted.
- classes/forum_service.go (CreateThread, Reply) and classes/lifecycle.go (EditPost): call `breakwater.Render(body)` before the Fire and pass the result as `HTML`; the transaction stores the same value. Behaviour without a listener is byte-identical (the render only moves up). Update the doc comments of the three methods.
- saving_test.go: add TestPostSavingHTML per the behavior block.
- README.md: Features "Pre-save seam" (fires after render, carries HTML), API reference row for PostSaving with HTML, the acme listener example also checking `ev.HTML`, Testing (TestPostSavingHTML), Changelog `### v0.1.2` (PostSaving carries the rendered HTML; additive, no route, payload, table, migration or config change). Commit in sm-forum-plugin: `feat: PostSaving carries the rendered post HTML`.
sm-moderation-plugin:
- classes/html.go: `MatchHTML(words, links []string, html string) *Violation` (pure) and `(*Filter).CheckHTML(ctx, html string) (*Violation, error)` (the same settings row, enabled switch, cache and translated message as Check). Walk the HTML with the golang.org/x/net/html tokenizer: text tokens (the tokenizer decodes entities) appended as they come, with no separator for inline elements and a newline for block elements and br; the values of href and src checked as link candidates on their own; alt and title appended as text. Then run the Task 2 matcher (both forms) on the joined text, words before links, and on each attribute URL.
- classes/html_test.go: TestMatchHTML per the behavior block, rendering with breakwater (a framework module the plugin already requires).
- README.md: Usage gets the host pattern for rendered content (check the source text with Check and the rendered HTML with CheckHTML, as in the acme example); API reference rows for MatchHTML and Filter.CheckHTML; Testing; the v0.1.1 changelog entry gains "MatchHTML and Filter.CheckHTML read rendered HTML". Commit in sm-moderation-plugin: `feat: MatchHTML and Filter.CheckHTML for rendered content`.
- Read ../sm-demo-app/plugins/demo/moderation.go and write into the SUMMARY: checkMessage needs no change; checkPost needs `CheckHTML(ctx, ev.HTML)` after title and body (field body), which 20-09 adds after bumping the demo to these tags. Do not edit sm-demo-app here.</action>
<verify>
<automated>go -C ../sm-forum-plugin vet ./... &amp;&amp; go -C ../sm-forum-plugin test -count=1 -short ./... &amp;&amp; go -C ../sm-forum-plugin test -count=1 -run '^(TestPostSavingNoListener|TestPostSavingRejects|TestPostSavingHTML|TestPostSavingTracer)$' . &amp;&amp; go -C ../sm-moderation-plugin vet ./... &amp;&amp; go -C ../sm-moderation-plugin test -count=1 -short -run '^TestMatchHTML$' -v ./classes 2>&amp;1 | grep -q -- '^--- PASS: TestMatchHTML' &amp;&amp; go -C ../sm-moderation-plugin test -count=1 ./...</automated>
<fails_when>Non-zero exit: forum vet or tests fail (the HTML field differs from the stored content_html, the no-listener path changed, a refusal stopped working), TestMatchHTML is missing or red (a rendered probe passes, clean Markdown is flagged), or any moderation test fails.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE 'HTML: *html' ../sm-forum-plugin/classes/forum_service.go` prints 2 and `grep -cE 'HTML: *html' ../sm-forum-plugin/classes/lifecycle.go` prints 1.
- `grep -c '### v0.1.2' ../sm-forum-plugin/README.md` prints 1.
- `grep -cE 'MatchHTML|CheckHTML' ../sm-moderation-plugin/README.md` prints at least 3.
</acceptance_criteria>
<done>A host can refuse a forum post by what its readers will see, and the two plugins are ready for their patch tags with every bypass pinned by a test.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| member text to the filter | untrusted chat messages, forum titles and Markdown bodies, any Unicode |
| filter to the member's browser | what passes is rendered as text, links and link cards by the chat renderer, goldmark and the WHATWG URL parser |
| admin settings to the filter | banned entries typed by an administrator, any Unicode |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-38 | Spoofing | banned-link matching (classes/normalize.go, filter.go) | high | mitigate | candidates are canonicalised the way browsers resolve them (percent-decoded authority, IDNA ASCII, dots mapped, port and userinfo dropped, special-scheme slashes, cleaned path) and compared with entries in the same form; TestMatchBypassLinks pins every form from CR-02 |
| T-20-39 | Tampering | banned-word matching | high | mitigate | NFKC, format characters dropped, Unicode spaces folded, diacritics folded on both sides, entities decoded, inline markup removed in a second pass; TestMatchBypassWords pins every form from CR-03 |
| T-20-40 | Tampering | forum Markdown rendered after the check | high | mitigate | PostSaving carries the rendered HTML and CheckHTML reads it as a member sees it; TestPostSavingHTML and TestMatchHTML pin the rendered forms (wired in the demo in 20-09) |
| T-20-41 | Denial of service | normalising and matching large texts | low | accept | RE2 is linear, lists are bounded by max_entries and max_entry_length, the work is two passes over a message the plugins already cap in size |
| T-20-SC | Tampering | Go module installs | high | mitigate | no new module: golang.org/x/text v0.42.0 and golang.org/x/net v0.58.0 are already direct requires of the framework and in go.sum; tidy runs with GOPROXY=off, so nothing is fetched |
</threat_model>
<verification>
- Task 1: vet, short tests, the two tracer bypass tests and the Docker TestFilterCheckNormalises.
- Task 2: the full moderation suite with Docker; every reviewer probe flagged; one existing expectation flipped on purpose.
- Task 3: forum suite with the HTML seam test and the unchanged seam tests; TestMatchHTML on breakwater output; the demo listener finding recorded for 20-09.
</verification>
<success_criteria>
Every bypass in CR-02 and CR-03 is refused by the filter and pinned by a regression test, the forum seam passes the rendered post (D-18, additive), clean text is still accepted, and both plugins are committed, untagged, ready for 20-09's release gate.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-07-SUMMARY.md` when done: the commits per repo, the probe table (form, before, after), the one changed expectation, the dependency note, and the listener finding for 20-09 (checkMessage unchanged, checkPost adds CheckHTML on ev.HTML).
</output>

View File

@@ -0,0 +1,185 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 07
subsystem: moderation filter (sm-moderation-plugin) and forum pre-save seam (sm-forum-plugin)
status: complete
gap_closure: true
review_findings: [CR-02, CR-03]
tags: [moderation, normalisation, idna, nfkc, forum, seam, security]
requires: [20-06]
provides:
- "classes.Match / Filter.Check normalise text and links before matching (CR-02, CR-03)"
- "classes.MatchHTML / Filter.CheckHTML read rendered HTML as members see it"
- "models.CanonicalHost; models.LinkEntry returns the IDNA ASCII host"
- "forum events.PostSaving.HTML (the content_html that is stored)"
affects: [20-09 (dependency bumps, demo listener, tags)]
tech-stack:
added: []
patterns:
- "normalise both sides: entries and text through the same fold, entry reported as listed"
- "two passes per text (as typed, without inline markup), words before links"
- "link candidates canonicalised like the WHATWG URL parser (percent-decoded authority, last @, port dropped, IDNA ASCII, special-scheme slashes, cleaned path)"
key-files:
created:
- ../sm-moderation-plugin/classes/normalize.go
- ../sm-moderation-plugin/classes/html.go
- ../sm-moderation-plugin/classes/bypass_test.go
- ../sm-moderation-plugin/classes/html_test.go
modified:
- ../sm-moderation-plugin/classes/filter.go
- ../sm-moderation-plugin/classes/filter_test.go
- ../sm-moderation-plugin/models/settings.go
- ../sm-moderation-plugin/models/settings_test.go
- ../sm-moderation-plugin/plugin_test.go
- ../sm-moderation-plugin/go.mod
- ../sm-moderation-plugin/README.md
- ../sm-forum-plugin/events/events.go
- ../sm-forum-plugin/classes/forum_service.go
- ../sm-forum-plugin/classes/lifecycle.go
- ../sm-forum-plugin/saving_test.go
- ../sm-forum-plugin/README.md
decisions:
- "Hosts go through a WHATWG-like UTS #46 profile (lookup mapping, bidi and joiner rules, CheckHyphens and STD3 off) rather than idna.Lookup, which refuses names browsers open (ab--c.example)"
- "The authority is split by hand (last @, last :) instead of net/url, which errors on escapes and odd ports and would drop the candidate"
- "The old host regexp stays as a second candidate source next to tokenising, so every host the old matcher found (url=bad.example) is still found"
- "The markup pass also drops a backslash before ASCII punctuation, so the raw check refuses bit\\.ly too"
- "Combining marks Mn and Me are dropped when folding; Cc controls that are not spaces are dropped with Cf"
metrics:
duration: 20min
started: 2026-11-02T17:29:01Z
completed: 2026-11-02T17:49:03Z
tasks: 3
files: 16
actuals:
tokens: 17100
tasks: 3
commits: 4
plan_head_before: "sm-moderation-plugin 313d117, sm-forum-plugin 13aa642 (summercms.go: no code commits)"
plan_head_after: "sm-moderation-plugin 334697b, sm-forum-plugin ec1a82c"
---
# Phase 20 Plan 07: Filter hardening for CR-02 and CR-03 Summary
The moderation filter now matches text and links the way a member reads and a browser opens them: NFKC, dropped format characters, Unicode spaces, folded diacritics, entities and inline markup for words; percent-decoding, IDNA ASCII, mapped dots, port and userinfo, special-scheme slashes and cleaned paths for links. Rendered forum HTML can be checked with `CheckHTML`, and forum's `PostSaving` now carries that HTML.
## Commits
The plan's code lives in the two sibling repos. Nothing was pushed or tagged. The `commits: 4` above was counted with `git rev-list --count` from each repo's base. The summercms.go repo has no code commits; this SUMMARY is left uncommitted for the orchestrator.
| Repo | Hash | Subject |
|------|------|---------|
| sm-moderation-plugin | 266fc60 | fix: normalise text and link hosts before matching banned entries |
| sm-moderation-plugin | 9715ce7 | fix: match banned words and links in the forms browsers show |
| sm-moderation-plugin | 334697b | feat: MatchHTML and Filter.CheckHTML for rendered content |
| sm-forum-plugin | ec1a82c | feat: PostSaving carries the rendered post HTML |
## Probe table
Lists: words `buy followers`, `casino bonus`, `free crypto`, `viagra`; links `bit.ly`, `example.com/promo`. "Before" is v0.1.0 (313d117); every row was also checked with the reviewer's scratchpad probe, which now prints no `flagged=false` line.
| Form | Before | After | Where pinned |
|------|--------|-------|--------------|
| `via<U+200B>gra`, soft hyphen, ZWJ, word joiner, BOM | clean | word viagra | TestMatchBypassWords |
| `buy<NBSP/U+2009/U+202F/U+3000>followers` | clean | word buy followers | TestMatchBypassWords |
| fullwidth `viagra` | clean | word viagra | TestMatchBypassWords |
| `vi&#97;gra`, `vi&#x61;gra` | clean | word viagra | TestMatchBypassWords, TestMatchHTML |
| `via**gra**`, ``via`gra` ``, `via~~gra~~`, `_viagra_` | clean | word viagra | TestMatchBypassWords, TestMatchHTML |
| NFD `żółw`, folded `zolw` (entry żółw) | clean | word żółw (as listed) | TestMatchBypassWords |
| `https://bit%2Ely/x`, `https://bit.l%79/x`, `bit.l%79/x` | clean | link bit.ly | TestMatchBypassLinks, TestFilterCheckNormalises |
| `https://bit.ly/x`, `bit。ly`, `bit.ly`, `bit。ly` | clean | link bit.ly | TestMatchBypassLinks |
| `https://user@bit.ly/x`, `https://example.com@bit.ly/` | flagged (the regexp found bit.ly after the @) | link bit.ly | TestMatchBypassLinks |
| `[x](https://bit&#46;ly/a)` | clean | link bit.ly | TestMatchBypassLinks, TestMatchHTML |
| `https:bit.ly`, `https:/\bit.ly/x`, `https:\\bit.ly` | flagged (regexp found the bare host) | link bit.ly | TestMatchBypassLinks |
| `https://bit.ly./x` | flagged | link bit.ly | TestMatchBypassLinks |
| `example.com:443/promo`, `/%70romo`, `//promo`, `/./promo`, `/x/../promo`, `example.com./promo` | clean | link example.com/promo | TestMatchBypassLinks |
| `https://sub.example.com/promo` | clean | link example.com/promo | TestMatchBypassLinks |
| punycode against a Unicode entry, and the reverse | clean | link (entry as listed) | TestMatchBypassLinks |
| rendered `via<!-- x -->gra`, `bit\.ly`, reference link with an entity, `<https://bit.ly/x>` | clean (raw) | flagged by MatchHTML | TestMatchHTML |
| clean: `spam_bot`, `_spam`, `spamming`, `notbit.ly`, `/about`, `/x/promo`, `bit.lyx`, `<p>via</p><p>gra</p>` | clean | clean | bypass and HTML tests |
## The one changed expectation
`TestMatchLinks`: `a.shop.example/promo` against the entry `https://shop.example/promo` was clean and now flags (KindLink, entry as listed). Path rules cover subdomains, as host rules do (CR-02). `git diff --stat 313d117 -- classes/filter_test.go` shows 2 insertions (the comment and the case) and 1 deletion. No other existing case changed.
`TestLinkEntry`: the `żółw.example` case now expects `xn--w-uga1v8h.example`. New cases cover fullwidth letters, U+3002 and U+FF61 dots, an ASCII punycode entry, `ab--c.example` (accepted) and the invalid punycode `xn--zz.example` (refused).
## Dependency note
`golang.org/x/text v0.42.0` (unicode/norm) and `golang.org/x/net v0.58.0` (idna, html) moved from indirect to direct requires of sm-moderation-plugin. The framework already requires both directly at these versions. `go mod tidy` ran with `GOPROXY=off`, go.sum did not change, and no new module entered the build. The README's Dependencies section names both. `go list -deps ./...` shows no sm-chat-plugin or sm-forum-plugin import.
## Listener finding for 20-09
`../sm-demo-app/plugins/demo/moderation.go` (read only, not edited):
- `checkMessage` needs no change: `Check` now normalises the chat text itself.
- `checkPost` should call `f.CheckHTML(ctx, ev.HTML)` after the title and body checks, and reject under field `body` when it flags. This only compiles against the new tags (sm-forum-plugin v0.1.2 for `PostSaving.HTML`, sm-moderation-plugin v0.1.1 for `CheckHTML`). The demo's go.work uses the submodule checkouts at `plugins/golem15/{forum,moderation}`, currently at v0.1.1 and v0.1.0. So the edit belongs right after 20-09 bumps those submodules.
- Even without that edit, the raw `Check` on the body now refuses `via**gra**`, `vi&#97;gra`, `_viagra_`, `[x](https://bit&#46;ly/a)`, `[x](https:bit.ly)` and `bit\.ly`. Only the forms the renderer alone resolves, such as a comment inside a word or a reference-link destination with an entity, need `CheckHTML`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] TestPostSavingNoListener had to gain the HTML field**
- **Found during:** Task 3
- **Issue:** The plan said the existing seam tests pass unedited. But `TestPostSavingNoListener` compares whole `PostSaving` values with `slices.Equal`, so the new field made it fail.
- **Fix:** The three expected events now carry `HTML: breakwater.Render(body)`. Nothing else in the test changed. `TestPostSavingRejects`, `TestPostSavingTracer`, the golden and method-map tests are unedited and pass.
- **Files modified:** ../sm-forum-plugin/saving_test.go
- **Commit:** ec1a82c
**2. [Rule 1 - Bug] WHATWG-like IDNA profile instead of `idna.Lookup`**
- **Found during:** Task 2
- **Issue:** `idna.Lookup` checks hyphens. It refuses `ab--c.example`, a name browsers open. Used in `LinkEntry`, that would make the admin form reject a valid entry.
- **Fix:** `models.CanonicalHost` uses `idna.New(MapForLookup, BidiRule, Transitional(false), CheckHyphens(false), StrictDomainName(false), CheckJoiners(true))`, which is UTS #46 as the URL standard applies it. Punycode results are the same (`żółw.example` → `xn--w-uga1v8h.example`). It is one exported function used by both `LinkEntry` and the matcher, so entries and candidates are always read alike.
- **Files modified:** ../sm-moderation-plugin/models/settings.go
- **Commit:** 9715ce7
**3. [Rule 1 - Bug] Authority split by hand, not with net/url**
- **Issue:** `url.Parse` errors on some decoded hosts and on non-numeric ports. Each such error would silently drop a candidate.
- **Fix:** `canonicalLink` takes the text after the last `@`, drops a `:port` and passes the host to CanonicalHost. This gives the same result as `Hostname()` for valid URLs and never drops a candidate.
- **Commit:** 266fc60
**4. [Rule 2 - Missing functionality] The host regexp stays next to tokenising**
- **Issue:** The plan said to replace the regexp with tokenising. Tokens alone miss a host glued to other text (`url=bad.example`, `a,bad.example`), which the old matcher found.
- **Fix:** `matchLinks` checks the tokens first, then every `hostPattern` match (widened for percent-escapes). The union can only add matches, and every must-stay-clean case still passes.
- **Commit:** 9715ce7
**5. [Rule 2 - Missing functionality] Small additions to the normaliser**
- The markup pass also drops a backslash before ASCII punctuation (a Markdown escape), so the raw check refuses `bit\.ly`.
- Controls (Cc) that are not spaces are dropped along with Cf.
- Diacritic folding drops enclosing marks (Me) along with Mn.
- **Commit:** 266fc60, 9715ce7
**6. [Rule 2 - Test coverage] Extra tests**
- `TestFilterCheckNormalises` also covers `CheckHTML` through the stored lists: word, link with the pl message, clean, disabled.
- `TestMatchHTMLWalk` pins the tokenizer walk: inline joins, block/br/td separation, href, src, alt and title, comments and scripts.
- **Commit:** 334697b
**7. [Rule 1 - Bug] Invisible characters escaped in test sources**
- The first bypass test was committed with a literal U+200B in its source (266fc60). In 9715ce7 every invisible or combining character in the test sources became a `\uXXXX` escape, so the cases can be read and reviewed.
### TDD notes
- Task 1: RED was seen before any code changed. Both tracer tests failed with "got no violation".
- Task 2: RED was seen before the code changed: 19 "got no violation" failures across TestMatchBypassWords, TestMatchBypassLinks and the flipped TestMatchLinks case.
- Task 3: the HTML tests were written after `html.go`, and both passed on their first run. RED was not observed separately.
## Test results
- sm-moderation-plugin: `go vet ./...` is clean. `go test -short ./...` and `go test ./...` (Docker Postgres) pass. That covers TestMatchBypassWords, TestMatchBypassLinks, TestMatchHTML, TestMatchHTMLWalk, TestLinkEntry and TestFilterCheckNormalises.
- sm-forum-plugin: `go vet ./...` is clean. `go test -short ./...` and `go test ./...` (Docker) pass, including TestPostSavingTracer, TestPostSavingNoListener, TestPostSavingRejects and TestPostSavingHTML.
- Reviewer probe (scratchpad modtest): all 20 cases print `flagged=true`.
- Acceptance greps: go.mod has 2 direct x/text and x/net lines. There are 0 chat/forum deps. `HTML: html` appears 2 times in forum_service.go and once in lifecycle.go. Each README has one changelog heading (`### v0.1.1` moderation, `### v0.1.2` forum). MatchHTML/CheckHTML appear 11 times in the moderation README.
## Known limitations (not stubs)
- Path rules are prefixes, as before: `example.com/promo` also matches `/promotional`. This behaviour is older than this plan and was not changed.
- IPv4 forms a browser normalises (`0x7f.1`, dotless decimals) and IPv6 literals are not canonicalised. No listed entry in the review relies on them.
## Threat Flags
None. No new endpoint, auth path, file access or schema change. PostSaving gains a read-only field.
## Self-Check: PASSED
- FOUND: classes/normalize.go, classes/html.go, classes/bypass_test.go, classes/html_test.go (sm-moderation-plugin)
- FOUND commits: 266fc60, 9715ce7, 334697b (sm-moderation-plugin), ec1a82c (sm-forum-plugin)

View File

@@ -0,0 +1,324 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 08
type: execute
wave: 7
depends_on: ["20-06"]
gap_closure: true
review_findings: [CR-01, WR-01, WR-04, WR-05, CR-02]
files_modified:
- ../vue-chatvideo-layer/app/composables/useCall.ts
- ../vue-chatvideo-layer/app/lib/videoClient.ts
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
- ../vue-chatvideo-layer/i18n/locales/en.json
- ../vue-chatvideo-layer/i18n/locales/pl.json
- ../vue-chatvideo-layer/tests/useCall.test.ts
- ../vue-chatvideo-layer/tests/videoClient.test.ts
- ../vue-chatvideo-layer/README.md
- ../vue-chat-layer/app/lib/urlGuard.ts
- ../vue-chat-layer/app/lib/urlGuard.test.ts
- ../vue-chat-layer/app/lib/markdown.ts
- ../vue-chat-layer/app/lib/linkEmbeds.ts
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
- ../vue-chat-layer/README.md
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
autonomous: true
requirements: [SC-2, D-02, D-04, D-11, D-13, D-17, D-23]
estimate:
tokens: 230000
raw_tokens: 230000
tasks: 5
confidence: low
must_haves:
truths:
- "CR-01, D-13: a call the user leaves, or whose page unmounts, while start or join is pending, while livekit-client loads, while LiveKit connects or between enabling the camera and the microphone never publishes media after the leave: no Room connects after it, setCameraEnabled and setMicrophoneEnabled are not called after it, a Room already created is disconnected, and once the server session exists the starter posts end and the callee posts leave. Leave works in the starting and connecting states. A second begin while one is pending creates no second Room."
- "WR-05: a failed LiveKit connect posts end when the session has nobody joined besides this user (GET status), and leave otherwise; when status cannot be read, the starter posts end and the callee leave."
- "WR-04, D-11, D-13, D-23: ChatvideoCall starts media on mount only when an in-memory handoff younger than a minute shows the user just clicked: the DM call button's start answer (start) or the incoming prompt's Accept (join). A direct load or a chat link to /call/{id}, with or without ?start=1, shows a confirmation with a Start call or Join call button and calls neither video/start nor video/join and opens no LiveKit connection until it is clicked."
- "CR-02 (client side), D-02, D-17: the chat renders as text, with no link and no link card, any http(s) URL whose host as written is not the host the browser opens: a percent-escape, userinfo or a backslash in the authority, ideographic, fullwidth or halfwidth dots, fullwidth letters, a trailing dot, or a scheme not followed by exactly two slashes. Honest URLs, IDN hosts written in Unicode, ports, queries and fragments still link and embed. bm's copied chat tests pass unchanged."
- "WR-01, D-04: logging out (header or login page) and the chat's unauthorized() reset the chat store, close the shared realtime socket and dismiss a ringing call prompt in the same tab; one failing part does not stop the others; the next login starts from empty chat state with a fresh connection token."
- "Every fix has vitest regression tests in the repository that owns it; vue-demo-app builds and passes its tests on its current layer pins (v0.1.0 already exports useChat().reset, the realtime disconnect and useIncomingCalls().dismiss); nothing is pushed or tagged here."
artifacts:
- path: "../vue-chatvideo-layer/app/composables/useCall.ts"
provides: "per-attempt token, stale checks after every await, leave from the pre-room states, failed-connect end rule"
contains: "attempt"
- path: "../vue-chatvideo-layer/app/lib/videoClient.ts"
provides: "hasStart, rememberAccept, takeAccept and callAutostart (the click handoffs)"
contains: "callAutostart"
- path: "../vue-chatvideo-layer/app/components/Chatvideo/Call.vue"
provides: "the join confirmation"
contains: "chatvideo-confirm"
- path: "../vue-chat-layer/app/lib/urlGuard.ts"
provides: "honestHttpUrl"
contains: "honestHttpUrl"
- path: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts"
provides: "createCommunitySignOut and endSession"
contains: "createCommunitySignOut"
key_links:
- from: "../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue"
to: "../vue-chatvideo-layer/app/lib/videoClient.ts"
via: "Accept leaves the join handoff before it navigates"
pattern: "rememberAccept"
- from: "../vue-chatvideo-layer/app/components/Chatvideo/Call.vue"
to: "../vue-chatvideo-layer/app/lib/videoClient.ts"
via: "mount begins only when callAutostart answers true"
pattern: "callAutostart"
- from: "../vue-chat-layer/app/lib/markdown.ts"
to: "../vue-chat-layer/app/lib/urlGuard.ts"
via: "safeUrl allows an http(s) target only through honestHttpUrl"
pattern: "honestHttpUrl"
- from: "../vue-chat-layer/app/lib/linkEmbeds.ts"
to: "../vue-chat-layer/app/lib/urlGuard.ts"
via: "linkify and messageUrls parse through honestHttpUrl"
pattern: "honestHttpUrl"
- from: "../sm-demo-app/vue-demo-app/app/plugins/auth.ts"
to: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts"
via: "the plugin builds the sign-out and hands it to createChatAuth and to logout"
pattern: "createCommunitySignOut"
prohibitions:
- statement: "No push, tag or pin change in this plan; vue-demo-app keeps its v0.1.0 layer pins and the sm-demo-app gitlink is not bumped (20-09)."
status: unresolved
verification: judgment
- statement: "No new package in any package.json or lockfile; tests use vitest, vi.mock and pure helpers (no @vue/test-utils, which no lockfile has)."
status: unresolved
verification: automated
- statement: "Neither layer names a consuming application or a host path."
status: unresolved
verification: automated
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public site shows them working, video calls included.
This plan's slice (gap round after the code review, plan 8 of 9, wave 7, parallel with 20-07: no shared file): the call and session fixes from 20-REVIEW.md in the repositories that own the code.
- CR-01: leaving or unmounting during start or join still connected and published the camera and microphone.
- WR-05: a failed connect posted leave and left an empty ringing session.
- WR-04: a link opened a call and turned media on with no confirmation.
- WR-01: logout left the previous user's chat store, realtime socket and call prompt running.
- The client half of CR-02: the chat's link-embed rendering gets the guard the brief asked for; the server half is 20-07.
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix here ships with vitest regression tests in the repository that owns it, and 20-09 runs every touched repository's full suite before anything is tagged.
## Decisions taken in this plan (Claude's discretion, recorded)
- **One attempt token** (the review's fix shape). `begin` increments an attempt counter and dispatches `start` before its first await, for the callee too, so the state leaves idle at once and Leave and unmount always see a live attempt. After every await (the start or join answer, the livekit-client import, connect, each media toggle) it bails out when the attempt is stale. `leave()` invalidates the attempt. The call machine already moves starting → connecting on `joined`, so it needs no change.
- **Who posts what after a stale answer.** When the start or join answer arrives after a leave, the server session exists only from that moment, so `begin`'s stale branch posts `end` (starter) or `leave` (callee) itself. `leave()` posts only when an answer has already arrived for the attempt. This avoids the 404 that the current code's premature `end` produced.
- **"Alone" after a failed connect (WR-05).** The review's formula (`start || remoteParticipants.size === 0`) would end the call for a waiting caller whenever a callee's connect fails, because a room that never connected has no remote participants. The plan uses the server's view instead: GET status, alone when at most one participant has status `joined` (chatvideo marks the starter joined at start and the callee at join). Fallback on a status error: the starter ends, the callee leaves.
- **Confirmation (WR-04).** The autostart decision is a pure helper, `callAutostart(conversationId, start)`, over in-memory handoffs that a link cannot create: the call button's start answer (existing `rememberStart`, now readable with `hasStart` without taking it) and a new Accept marker (`rememberAccept`, `takeAccept`), each valid for a minute. `?start=1` stays in the demo's call path but only chooses Start over Join on the confirmation. The rule is tested as a helper because @vue/test-utils is in no lockfile (20-06 deviation 1). The demo's call page needs no code change. Its doc comment is updated together with the pin bump in 20-09, so it never describes behaviour that the pinned layer lacks.
- **Link guard (CR-02 client side).** It lives in a new `app/lib/urlGuard.ts` with `honestHttpUrl(raw)`, used by `safeUrl` (markdown.ts) and by `parseHttp` (linkEmbeds.ts). A deceptive URL stays text: no anchor and no card. The server now refuses banned ones (20-07). The client guard covers any host, so a card never shows a different host than the text. bm's copied tests (chatText.test.ts and the other four) stay byte-identical, so the new cases go into urlGuard.test.ts and linkEmbeds.test.ts.
- **Sign-out (WR-01).** The review's explicit-call option, not a `watch(loggedIn)`: the token store is document.cookie, which a useCookie watcher would not see when `unauthorized()` clears it. `createCommunitySignOut` and `endSession` are Nuxt-free factories in `app/utils/communityAuth.ts`, tested with vitest. The plugin builds the sign-out with `nuxtApp.runWithContext` and hands it to `createChatAuth` (unauthorized) and, through `$communitySignOut`, to `useDemoAuth().logout()`. SiteHeader.vue and login.vue already go through `logout()` and need no change. Clearing `chatvideo:incoming` uses `useIncomingCalls().dismiss()`, which also stops the ring timer and closes the session handle. The handle leak between two pending subscribes is IN-07 and stays deferred.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
<objective>
Make leaving a call final at any moment, end empty sessions after a failed connect, ask before a call page starts the camera and microphone, keep deceptive link hosts out of chat links and cards, and reset the previous user's chat, socket and call prompt on logout.
Purpose: D-13 (the minimal call page) and D-11/D-23 (DM calls from the call button) behave as the user expects. D-02 and D-17 hold in the chat renderer. D-04 holds after logout (no chat for a signed-out visitor).
Output: commits in ../vue-chatvideo-layer, ../vue-chat-layer and ../sm-demo-app/vue-demo-app (not pushed, not tagged, pins unchanged), each with vitest regression tests.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-CONTEXT.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
Repos: ../vue-chatvideo-layer (main at 8222255, tag v0.1.0), ../vue-chat-layer (main at b6d3389, tag v0.1.0; 764cb42 and b6d3389 are already on main after the tag), ../sm-demo-app/vue-demo-app (the submodule, master at deb809f; pins vue-chat-layer and vue-chatvideo-layer v0.1.0 by git+https tag). Test runners: `pnpm --dir <repo> test` (vitest 3.2.7); the layers' SFCs compile with `pnpm --dir <layer> run build:playground`; vue-demo-app builds with `pnpm --dir ../sm-demo-app/vue-demo-app run build`.
Rules: no co-author tags; stage files by explicit path; no push, tag or pin change (20-09); a layer's README changes in the same commit as its API; the layers name no application; check `git status` in each repo before editing and leave edits that are not this plan's alone (vue-demo-app often has a stale `nuxt dev` from 2026-10-22 running, pids 212159 and 212194, leave it alone). Do not edit sm-demo-app itself; its browser checks for these fixes are written in 20-09 Task 1, where they run against the unreleased layers.
<interfaces>
vue-chatvideo-layer today:
- app/composables/useCall.ts: `useCall(conversationId)` returns {state, tiles, declined, audioBlocked, reconnecting, mediaError, begin(start), leave(), toggleMute(), toggleCamera(), startAudio()}; module-level `room`, `lk`, `channel`, `leaving`; begin: guard on `status !== 'idle' && isLive`, `dispatch start` only for the starter, `await client.start/join` (or takeStart), `joined`, pagehide listener, `openChannel(session_id)`, `await import('livekit-client')`, `room = new Room(...)`, `await r.connect`, a check that room is still r, `connected`, `await setCameraEnabled(true)`, `await setMicrophoneEnabled(true)`; on connect failure it posts leave. `leave()` returns early on idle; it posts end when alone (no room or no remote participants) else leave, then disconnects. onBeforeUnmount calls leave.
- app/lib/callMachine.ts: states idle, starting, connecting, connected, ended, failed; `joined` accepted from idle or starting; `isLive`; unchanged by this plan.
- app/lib/videoClient.ts: `createVideoClient` {start, join, leave, end, status → VideoStatus {session_id, conversation_id, status, started_by, participants [{user_id, status}]} or null}; `rememberStart`/`takeStart` (in-memory, a minute, taken once); `videoSessionChannel`; `parseIncomingCall`.
- Call.vue: props conversationId, start, backPath; `onMounted(() => call.begin(props.start))`; Leave enabled while starting, connecting or connected; retry calls begin. IncomingCall.vue: `accept()` takes the pending call and navigates to callPath; decline posts leave. Locale keys chatvideo.state.*, chatvideo.action.*, chatvideo.reason.*.
- tests/useCall.test.ts: vi.mock of livekit-client (fake Room with connect, disconnect, localParticipant setters as vi.fn, `Room.instances`, `failConnect`, `onCreate`), vi.mock of vue-chat-layer realtime (`realtime.subs`), unmount hooks captured, a stub client (start, join, leave, end, status as vi.fn), `connected(start)` helper. The test "a failed LiveKit connect fails the call and leaves the session" expects leave; WR-05 changes it.
- chatvideo server facts: Start creates the session with the starter's participant row `joined` and the others `invited`; Join marks the actor `joined`; Leave marks `left`.
vue-chat-layer today: app/lib/markdown.ts `safeUrl(raw)` (http(s) with `//` and a non-slash, mailto, site paths, anchors), `escapeHtml`, `formatSpans`; app/lib/chatText.ts `renderChatText` (LINK_RE, URL_RE through safeUrl); app/lib/linkEmbeds.ts `parseHttp(raw)` (new URL, http(s) only), `linkify`, `messageUrls`, `registerEmbedRenderer`, `embedRendererFor`; Chat/LinkEmbed.vue shows url.host and links url.href; useChat `reset()` (exported in v0.1.0); app/lib/realtime.ts `disconnect()` (exported in v0.1.0).
vue-demo-app today: app/utils/communityAuth.ts {CommunityTokens, CommunityFetch, tokenGetter, createChatAuth(store, fetchJson) → {token, refresh, unauthorized}, friendsDirectory}; app/plugins/auth.ts provides paymentGatewayAuth, chatAuth, forumAuth, userfriendsAuth, userfriendsDirectory and calls setRealtimeAuth; app/composables/useDemoAuth.ts `logout()` posts /_user/api/v1/logout with the token, then `token.value = null`; app/lib/realtime.ts re-exports vue-chat-layer's realtime (disconnect included); app/layouts/default.vue renders ChatFloatingPanel and ChatvideoIncomingCall for members.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the caller clicks Leave while the call is still starting, and nothing connects or turns on afterwards, under test</name>
<precondition>`git -C ../vue-chatvideo-layer status --porcelain` prints nothing; `pnpm --dir ../vue-chatvideo-layer test` passes before the change.</precondition>
<files>../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/tests/useCall.test.ts</files>
<read_first>../vue-chatvideo-layer/app/composables/useCall.ts (whole file), app/lib/callMachine.ts, tests/useCall.test.ts (whole file: the fakes, beforeEach, the connected helper, every existing test), 20-REVIEW.md CR-01</read_first>
<action>Per D-13, closing the caller path of CR-01 end to end.
1. Red first: in tests/useCall.test.ts add a small deferred-promise helper and the test "leave while start is pending: no room, no media, end once the session exists". `client.start` answers a deferred promise; call `begin(true)` without awaiting it, `await leave()`, then resolve the start answer and await the begin promise. Expect: no fake Room was connected (`Room.instances` empty, or none of them had connect called), setCameraEnabled and setMicrophoneEnabled never called, `client.end` called once with the conversation id after the answer arrived, the state ended with reason left, no open session channel, no pagehide listener. Run it and see it fail.
2. useCall.ts: add the per-attempt counter. begin increments it and keeps its own number, resets `leaving`, dispatches `start` before the first await (for both roles), and after the start or join answer checks whether the attempt is stale (another attempt started or leave ran); if so it posts `end` for a start or `leave` for a join (errors swallowed) and returns before subscribing, adding the pagehide listener or loading livekit-client. leave() no longer returns early while an attempt is in flight in the starting state: it marks leaving, invalidates the attempt, dispatches leave, posts end or leave only when this attempt already has its answer, and disconnects.
3. Keep every existing test green; update the module header comment (begin, leave and unmount semantics).
4. Commit in vue-chatvideo-layer: `fix: leaving while a call starts never connects it`.</action>
<verify>
<automated>pnpm --dir ../vue-chatvideo-layer test</automated>
<fails_when>Non-zero exit: the new test fails (a Room connects, a media setter runs, end is posted before or never, the state is not ended) or an existing useCall, callMachine or videoClient test breaks.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'leave while start is pending' ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
- `git -C ../vue-chatvideo-layer diff --stat 8222255 -- package.json pnpm-lock.yaml` prints nothing.
</acceptance_criteria>
<done>A caller who hangs up during "Connecting…" is out of the call for good: the late start answer ends the session and no camera or microphone turns on.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Every other moment of leaving is final too (callee join, unmount, livekit-client load, connect, between camera and microphone, a second begin), and a failed connect ends an empty session</name>
<files>../vue-chatvideo-layer/app/composables/useCall.ts, ../vue-chatvideo-layer/tests/useCall.test.ts, ../vue-chatvideo-layer/README.md</files>
<read_first>The Task 1 code and test, tests/useCall.test.ts (the fake Room's connect and setters, failConnect, onCreate, the unmount hooks), app/lib/videoClient.ts (status, VideoStatus), README.md (Usage, "The call flow", API reference, Testing)</read_first>
<behavior>
- "unmount while join is pending": begin(false) with a deferred join, run the captured unmount hook, resolve the join; no Room connects, no media setter runs, client.leave is posted once after the answer, state ended.
- "leave while connect is pending": connect answers a deferred promise; leave; resolve connect; setCameraEnabled and setMicrophoneEnabled never called, the Room disconnected, end posted once (alone).
- "leave between camera and microphone": setCameraEnabled answers a deferred promise; leave; resolve it; setMicrophoneEnabled never called, the Room disconnected.
- "leave while livekit-client loads": the import is awaited after the answer; stall it with a hoisted deferred gate in the mocked module if vitest allows it reliably, otherwise call leave in the same tick the answer resolves (the stale check right after the answer) and say which in the SUMMARY; no Room created after the leave, end posted once.
- "a second begin while the first join is pending": two begin(false) calls before the answer; join called once, at most one Room ever created and connected.
- WR-05 "a failed connect ends a session nobody else joined": failConnect set, client.status answers participants [{user_id: 7, status: 'joined'}, {user_id: 9, status: 'invited'}]; client.end posted, client.leave not. "a failed connect leaves when the other side joined": status answers two joined participants; client.leave posted, client.end not. "status unreadable": status rejects; the starter posts end, the callee posts leave.
- The existing test "a failed LiveKit connect fails the call and leaves the session" is renamed and changed to the WR-05 rule: the stub's status answers undefined, which counts as unreadable (as do null and a rejection), so the starter ends; every other existing expectation stays.
</behavior>
<action>Per D-13 and D-12 (the reconcile job and ringing behave as designed), closing the rest of CR-01 and WR-05.
- useCall.ts: after the livekit-client import, return when stale (no Room is created); after `connect` resolves, when stale disconnect that Room (disconnect(true), errors swallowed) and return; check staleness before each of setCameraEnabled and setMicrophoneEnabled, and after the camera, so a leave between them disconnects and skips the microphone; the Disconnected handler and finish() keep working for the current attempt only. On a connect failure: dispatch error, disconnect, then ask `client.status(conversationId)`; post end when at most one participant has status joined, else leave; on a status error post end for a start and leave for a join. Remove nothing else (tiles, declined, audio, reconnecting stay as they are).
- tests/useCall.test.ts: the behavior block's tests; keep the fakes, extend them only where a deferred is needed.
- README.md: "The call flow" says Leave works at any moment and what is posted when, the WR-05 rule, and Testing lists the new tests.
- Commit in vue-chatvideo-layer: `fix: a call left at any moment stays left, and a failed connect ends an empty session`.</action>
<verify>
<automated>pnpm --dir ../vue-chatvideo-layer test</automated>
<fails_when>Non-zero exit: any behavior-block test fails (media published after a leave, a second Room, a stale branch posting the wrong call, WR-05 posting leave for an empty session) or an existing test breaks.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE "test\('(unmount while join is pending|leave while connect is pending|leave between camera and microphone|a second begin while the first join is pending)" ../vue-chatvideo-layer/tests/useCall.test.ts` prints 4.
- `grep -c 'a failed connect ends a session nobody else joined' ../vue-chatvideo-layer/tests/useCall.test.ts` prints 1.
</acceptance_criteria>
<done>Whatever the moment a user leaves or navigates away, the camera and microphone stay off and the session is closed the right way, and a call that could not connect does not keep ringing.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: The call page asks before it starts media, unless the user just clicked the call button or Accept</name>
<files>../vue-chatvideo-layer/app/lib/videoClient.ts, ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue, ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue, ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css, ../vue-chatvideo-layer/i18n/locales/en.json, ../vue-chatvideo-layer/i18n/locales/pl.json, ../vue-chatvideo-layer/tests/videoClient.test.ts, ../vue-chatvideo-layer/README.md</files>
<read_first>app/lib/videoClient.ts (the handoff block: HANDOFF_MS, rememberStart, takeStart), app/components/Chatvideo/Call.vue, CallButton.vue, IncomingCall.vue, app/assets/css/chatvideo-base.css (button and note styles), i18n/locales/en.json and pl.json, tests/videoClient.test.ts (the handoff tests), README.md (Usage, Components, "The call flow"), ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue and app/pages/chat.vue (how the demo passes start and callPath; read only), 20-REVIEW.md WR-04</read_first>
<behavior>
- callAutostart(id, true) is false on a direct load; true right after rememberStart(id, …) within a minute, and begin's takeStart still receives that answer (hasStart does not take it); false after a minute; false for another conversation.
- callAutostart(id, false) is false on a direct load; true once right after rememberAccept(id) within a minute, then false (taken); false after a minute; false for another conversation; a start handoff does not make a join autostart.
- takeStart behaves as before (the existing handoff tests stay).
</behavior>
<action>Per D-11, D-13 and D-23, closing WR-04.
- app/lib/videoClient.ts: add `hasStart(conversationId, now?)` (a start answer younger than HANDOFF_MS is waiting; not taken), `rememberAccept(conversationId, now?)` and `takeAccept(conversationId, now?)` (an Accept marker, a minute, taken once; in memory, never in a URL or storage), and `callAutostart(conversationId, start, now?)` (start: hasStart; join: takeAccept). Doc comments say why: a link can open the call route but cannot create a handoff.
- IncomingCall.vue: accept() calls rememberAccept for the conversation before it navigates.
- Call.vue: on mount, begin only when callAutostart answers true; otherwise the idle state renders a confirmation block (class `chatvideo-confirm`) with the text chatvideo.confirm.start or chatvideo.confirm.join and one button (classes `chatvideo-button chatvideo-confirm__go`, label chatvideo.action.startCall or chatvideo.action.joinCall) that calls begin(start), plus the back link when backPath is set. Live, ended and failed states render as today; Retry stays an explicit click. Update the component's header comment (media starts only after a click on this page, the call button or Accept).
- Locales en and pl: confirm.start, confirm.join (each says the camera and microphone turn on after the click), action.startCall, action.joinCall. chatvideo-base.css: the confirm block in the existing card and button style.
- tests/videoClient.test.ts: the behavior block.
- README.md: Components (ChatvideoCall's confirmation; `start` picks Start or Join), "The call flow" (autostart only after the call button or Accept; a direct load or a link asks), API reference (hasStart, rememberAccept, takeAccept, callAutostart), Usage (the ?start=1 example now only selects Start), Testing.
- Commit in vue-chatvideo-layer: `feat: the call page asks before it starts the camera and microphone`.</action>
<verify>
<automated>pnpm --dir ../vue-chatvideo-layer test &amp;&amp; pnpm --dir ../vue-chatvideo-layer run build:playground</automated>
<fails_when>Non-zero exit: a handoff test fails (a direct load autostarts, Accept does not, hasStart takes the answer, a marker outlives a minute or crosses conversations), an earlier test breaks, or the playground build fails to compile Call.vue or IncomingCall.vue.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'callAutostart' ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue` prints at least 1 and `grep -c 'rememberAccept' ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue` prints at least 1.
- `node -e "for (const l of ['en','pl']) { const c = require('../vue-chatvideo-layer/i18n/locales/' + l + '.json').chatvideo; if (!c.confirm?.start || !c.confirm?.join || !c.action?.startCall || !c.action?.joinCall) process.exit(1) }"` exits 0.
- `grep -rniE 'golem15\.demo|sm-demo|demo\.summercms' ../vue-chatvideo-layer/app ../vue-chatvideo-layer/README.md | wc -l` prints 0.
</acceptance_criteria>
<done>Opening a call link shows a clear Start call or Join call button and nothing turns on until it is clicked, while the call button and Accept still go straight into the call.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: The chat renders deceptive link hosts as text, never as a link or a link card</name>
<files>../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts, ../vue-chat-layer/app/lib/markdown.ts, ../vue-chat-layer/app/lib/linkEmbeds.ts, ../vue-chat-layer/app/lib/linkEmbeds.test.ts, ../vue-chat-layer/README.md</files>
<read_first>../vue-chat-layer/app/lib/markdown.ts, app/lib/chatText.ts (LINK_RE, URL_RE, how safeUrl is used), app/lib/linkEmbeds.ts, app/lib/linkEmbeds.test.ts, app/components/Chat/LinkEmbed.vue, README.md (link embeds section, Testing), 20-REVIEW.md CR-02 (the table and the rendering paragraph)</read_first>
<behavior>
- honestHttpUrl refuses (returns null): https://bit%2Ely/x, https://bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, https://example.com@bit.ly/, https:/\bit.ly/x, https://\bit.ly/x, https://bit.ly./x, javascript: and data: URLs, and anything new URL cannot parse.
- honestHttpUrl accepts: https://bit.ly/x, http://example.com:8080/a?b=c#d, https://żółw.example/ (an IDN written in Unicode), https://xn--w-uga1v8h.example/, https://sub.example.org/path.
- renderChatText of each refused URL, bare and as [label](url), contains no anchor (the text shows as written, escaped); messageUrls of a text holding them returns none; linkify leaves them as text. The accepted URLs still render as links and still yield cards.
- bm's copied tests (chatEvents, chatFormat, chatText, chatUnread, realtime) pass byte-identical.
</behavior>
<action>Per D-02 (generic link embeds) and D-17, the client half of CR-02.
- app/lib/urlGuard.ts (new): `honestHttpUrl(raw: string): URL | null`. The URL must parse with new URL as http or https, and the scheme must be followed by exactly two slashes. The authority as written, up to the first slash, question mark or hash, must hold no percent sign, no at sign and no backslash. The host as written must hold none of U+3002, U+FF0E and U+FF61, must be unchanged by NFKC (no fullwidth or compatibility characters), and must not end with a dot. Its lower-cased form must equal url.hostname, or, for a host with non-ASCII letters, it must be in NFC with only U+002E as the separator. Doc comment: the chat links and embeds only URLs whose written host is the host the browser opens, so a card never shows a host the text does not.
- markdown.ts: safeUrl allows an http(s) target only when honestHttpUrl accepts it (mailto, site paths and anchors unchanged). linkEmbeds.ts: parseHttp delegates to honestHttpUrl, so linkify and messageUrls skip deceptive URLs.
- app/lib/urlGuard.test.ts (new): the behavior block, including renderChatText cases (import it from ./chatText). linkEmbeds.test.ts: messageUrls and linkify cases for two refused and two accepted URLs.
- README.md: the link embeds section describes the guard; Testing lists urlGuard.test.ts.
- Commit in vue-chat-layer: `fix: deceptive link hosts render as text, not links or cards`.</action>
<verify>
<automated>pnpm --dir ../vue-chat-layer test &amp;&amp; git -C ../vue-chat-layer diff --quiet b6d3389 -- app/lib/chatEvents.test.ts app/lib/chatFormat.test.ts app/lib/chatText.test.ts app/lib/chatUnread.test.ts app/lib/realtime.test.ts</automated>
<fails_when>Non-zero exit: a guard test fails (a deceptive URL becomes an anchor or a card, an honest or IDN URL stops linking), a store, client or embed test breaks, or one of bm's copied test files was edited.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'honestHttpUrl' ../vue-chat-layer/app/lib/markdown.ts` and `grep -c 'honestHttpUrl' ../vue-chat-layer/app/lib/linkEmbeds.ts` each print at least 1.
- `grep -rniE 'golem15\.demo|sm-demo|demo\.summercms' ../vue-chat-layer/app ../vue-chat-layer/README.md | wc -l` prints 0.
</acceptance_criteria>
<done>A message can no longer show a working link or a link card to a host it hides behind escapes, dots or userinfo; honest links work as before.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: Logging out resets the chat, closes the realtime socket and clears a ringing call in the same tab</name>
<files>../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts, ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts, ../sm-demo-app/vue-demo-app/app/plugins/auth.ts, ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts</files>
<read_first>../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts and communityAuth.test.ts (whole files), app/plugins/auth.ts, app/composables/useDemoAuth.ts (whole file), app/components/SiteHeader.vue and app/pages/login.vue (their logout calls), app/lib/realtime.ts (the re-export), app/layouts/default.vue, vitest.config.ts, ../vue-chat-layer/app/composables/useChat.ts (reset, lines around 1514) and app/lib/realtime.ts (disconnect), ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts (dismiss), 20-REVIEW.md WR-01</read_first>
<behavior>
- createCommunitySignOut({resetChat, disconnectRealtime, dismissCall}) runs the three in that order; a part that throws does not stop the next ones; calling it twice is safe.
- createChatAuth(store, fetch, onSignedOut): unauthorized() clears the token, then calls onSignedOut once; without onSignedOut it behaves as before (the existing tests pass unchanged).
- endSession(token, postLogout, clearToken, signOut): with a token it posts logout with that token, clears the token even when the post rejects, then signs out once; without a token it posts nothing and still clears and signs out.
</behavior>
<action>Per D-04 (no chat for a signed-out visitor), closing WR-01.
- app/utils/communityAuth.ts: add `createCommunitySignOut(parts)` and `endSession(token, postLogout, clearToken, signOut)` (Nuxt-free, doc comments), and an optional third parameter `onSignedOut` on `createChatAuth`, called by unauthorized() after the token is cleared. Update the file header.
- app/plugins/auth.ts: take the nuxtApp argument; build the sign-out with createCommunitySignOut whose parts run only in the browser: resetChat runs useChat().reset() inside nuxtApp.runWithContext, disconnectRealtime calls disconnect from ~/lib/realtime, dismissCall runs useIncomingCalls().dismiss() inside nuxtApp.runWithContext. Pass it to createChatAuth and provide it as `communitySignOut`. Update the header comment.
- app/composables/useDemoAuth.ts: read `$communitySignOut` from useNuxtApp() when the composable is created (before any await, so the Nuxt context is still there), and make logout() delegate to endSession with the current token, the existing logout POST, a clear that sets the token to null, and that sign-out. SiteHeader.vue and login.vue keep calling logout() unchanged.
- app/utils/communityAuth.test.ts: the behavior block, next to the existing tests.
- Commit in vue-demo-app only (not pushed; the sm-demo-app gitlink is bumped in 20-09): `fix: logging out resets the chat, closes the realtime socket and clears a ringing call`.</action>
<verify>
<automated>pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build</automated>
<fails_when>Non-zero exit: a sign-out or endSession test fails, an existing auth hook test breaks, or the site build fails (a missing auto-import or a layer API that v0.1.0 does not export).</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'createCommunitySignOut' ../sm-demo-app/vue-demo-app/app/plugins/auth.ts` prints at least 1 and `grep -c 'endSession' ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts` prints at least 1.
- `git -C ../sm-demo-app/vue-demo-app diff --stat deb809f -- package.json pnpm-lock.yaml app/components/SiteHeader.vue` prints nothing.
- `git -C ../sm-demo-app status --porcelain -- vue-demo-app` prints ` M vue-demo-app` (the gitlink moved, not staged; 20-09 commits the bump).
</acceptance_criteria>
<done>After logout nothing of the previous member stays in the tab: no chat state, no open socket, no ringing prompt, and the next member starts clean.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser camera and microphone to LiveKit | media leaves the device once a Room is connected and tracks are published |
| chat links to the call route | any member can post a same-site link to /call/{id} |
| chat text to rendered links and cards | member-written URLs become anchors and cards |
| one browser tab, two accounts | the previous member's state must not reach the next one |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-42 | Information disclosure | useCall (camera and microphone after leave) | critical | mitigate | per-attempt token checked after every await; leave invalidates it; a stale branch disconnects any Room and never calls the media setters; useCall tests pin each moment (start, join, import, connect, between camera and microphone, unmount, second begin) |
| T-20-43 | Elevation of privilege | /call/{id} opened from a link | high | mitigate | media starts on mount only with an in-memory handoff a link cannot create (call button start answer, Accept marker, a minute, taken once); otherwise the confirmation waits for a click; callAutostart tests |
| T-20-44 | Information disclosure | chat store, realtime socket and call prompt after logout | high | mitigate | logout and unauthorized run the community sign-out (reset, disconnect, dismiss), each part isolated; communityAuth tests; 20-09 adds a browser check that the socket closes |
| T-20-45 | Denial of service | empty ringing session after a failed connect | medium | mitigate | the failed-connect branch ends the session when GET status shows nobody else joined; tests for both outcomes and for an unreadable status |
| T-20-46 | Spoofing | deceptive hosts in chat anchors and link cards | high | mitigate | honestHttpUrl refuses escapes, userinfo, backslashes, compatibility dots and letters, trailing dots and malformed scheme separators before safeUrl, linkify or messageUrls produce a link or card; urlGuard tests |
| T-20-SC | Tampering | npm installs | high | mitigate | no package or lockfile change in this plan; acceptance checks the layer manifests are untouched |
</threat_model>
<verification>
- Task 1: vue-chatvideo-layer tests with the caller leave-during-start test.
- Task 2: the remaining CR-01 moments and WR-05 under test.
- Task 3: handoff tests and the playground build compiling the changed components.
- Task 4: vue-chat-layer tests with the guard, bm's copied tests untouched.
- Task 5: vue-demo-app tests and build on the v0.1.0 pins.
</verification>
<success_criteria>
CR-01, WR-01, WR-04, WR-05 and the client half of CR-02 are fixed in the repositories that own the code, each with regression tests; nothing is pushed, tagged or re-pinned, so 20-09 can run the full suites, integrate the unreleased layers locally and release them.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-08-SUMMARY.md` when done: the commits per repo, the new tests per finding, the changed expectation of the failed-connect test, the new layer exports (hasStart, rememberAccept, takeAccept, callAutostart, honestHttpUrl) and class names (chatvideo-confirm, chatvideo-confirm__go) that 20-09's browser checks use.
</output>

View File

@@ -0,0 +1,253 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 08
subsystem: chat-video-frontend
tags: [vue, nuxt, livekit, vitest, chat, video, auth, gap-closure]
status: complete
gap_closure: true
review_findings: [CR-01, WR-01, WR-04, WR-05, CR-02]
requires:
- phase: 20-06
provides: "useCall on a fake Room, the chat layer's tests, the community auth hooks and their tests"
provides:
- "useCall: a per-attempt token with stale checks after every await; leave works and is final at any moment"
- "useCall: a failed connect (or livekit-client import) ends a session nobody else joined (GET status)"
- "videoClient: hasStart, rememberAccept, takeAccept, callAutostart (click handoffs)"
- "ChatvideoCall: confirmation (chatvideo-confirm, chatvideo-confirm__go) unless the call button or Accept just handed over"
- "vue-chat-layer: honestHttpUrl (app/lib/urlGuard.ts), used by safeUrl, linkify and messageUrls"
- "vue-demo-app: createCommunitySignOut and endSession; logout and unauthorized() reset the chat, close the socket and dismiss the ring"
affects: [20-09]
actuals:
tokens: 16745 # chars/4 over the three repos' diffs (8222255..HEAD, b6d3389..HEAD, deb809f..HEAD)
tasks: 5
commits: 5 # measured per repo: vue-chatvideo-layer 3, vue-chat-layer 1, vue-demo-app 1; none in summercms.go
plan_head_before: "vue-chatvideo-layer 8222255, vue-chat-layer b6d3389, vue-demo-app deb809f"
plan_head_after: "vue-chatvideo-layer 5386903, vue-chat-layer a798bb1, vue-demo-app 188ae25"
tech-stack:
added: []
patterns:
- "Per-attempt object ({start, answered, left}) plus a stale() closure checked after every await of an async lifecycle"
- "In-memory click handoffs (a minute, taken once) as proof of user intent before media starts"
- "A written-host-equals-opened-host guard before any anchor or link card is produced"
- "A Nuxt-free sign-out factory whose parts run isolated, wired by the plugin with runWithContext"
key-files:
created:
- ../vue-chat-layer/app/lib/urlGuard.ts
- ../vue-chat-layer/app/lib/urlGuard.test.ts
modified:
- ../vue-chatvideo-layer/app/composables/useCall.ts
- ../vue-chatvideo-layer/app/lib/videoClient.ts
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
- ../vue-chatvideo-layer/app/assets/css/chatvideo-base.css
- ../vue-chatvideo-layer/i18n/locales/en.json
- ../vue-chatvideo-layer/i18n/locales/pl.json
- ../vue-chatvideo-layer/tests/useCall.test.ts
- ../vue-chatvideo-layer/tests/videoClient.test.ts
- ../vue-chatvideo-layer/README.md
- ../vue-chat-layer/app/lib/markdown.ts
- ../vue-chat-layer/app/lib/linkEmbeds.ts
- ../vue-chat-layer/app/lib/linkEmbeds.test.ts
- ../vue-chat-layer/README.md
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
key-decisions:
- "A stale start or join answer closes its session (end for the caller, leave for the callee) only when no newer attempt replaced it; a newer attempt owns the session"
- "leave() before the call connects posts end for the caller and leave for the callee (not the room-based alone rule), and only once the attempt has its answer"
- "WR-05 uses the server's view: GET status, end when at most one participant is joined; unreadable status (null, undefined, rejection) falls back to end for the caller and leave for the callee. The same rule applies to a failed livekit-client import"
- "callAutostart: start needs a waiting start answer (hasStart, left for begin to take); join takes an Accept marker. ?start=1 only chooses Start call over Join call"
- "honestHttpUrl also refuses invisible characters (Default_Ignorable_Code_Point, Cf) in a host, which the Unicode-host branch would otherwise accept"
patterns-established:
- "Leaving at every await of begin() is a test: deferred promises for start, join, connect and the camera setter, and a gated vi.doMock of livekit-client for the import"
requirements-completed: [SC-2, D-02, D-04, D-11, D-13, D-17, D-23]
coverage:
- id: D1
description: "CR-01: leaving or unmounting at any moment of a call attempt never connects a Room or turns on media afterwards"
requirement: D-13
verification:
- kind: unit
ref: "../vue-chatvideo-layer/tests/useCall.test.ts (leave while start is pending; unmount while join is pending; leave while livekit-client loads; leave while connect is pending; a connect that fails after a leave posts nothing more; leave between camera and microphone; a second begin while the first join is pending)"
status: pass
- id: D2
description: "WR-05: a failed connect ends a session nobody else joined, else leaves"
requirement: D-13
verification:
- kind: unit
ref: "../vue-chatvideo-layer/tests/useCall.test.ts (a failed LiveKit connect fails the call; an unreadable status ends the starter's session; a failed connect ends a session nobody else joined; a failed connect leaves when the other side joined; a failed connect with an unreadable status: the starter ends, the callee leaves)"
status: pass
- id: D3
description: "WR-04: the call page asks before media unless the call button or Accept just handed over"
requirement: D-11
verification:
- kind: unit
ref: "../vue-chatvideo-layer/tests/videoClient.test.ts (describe call autostart)"
status: pass
- kind: other
ref: "pnpm --dir ../vue-chatvideo-layer run build:playground (Call.vue and IncomingCall.vue compile)"
status: pass
- id: D4
description: "CR-02 client side: deceptive link hosts render as text, never as a link or a card"
requirement: D-17
verification:
- kind: unit
ref: "../vue-chat-layer/app/lib/urlGuard.test.ts; ../vue-chat-layer/app/lib/linkEmbeds.test.ts"
status: pass
- id: D5
description: "WR-01: logout and unauthorized() reset the chat, close the socket and dismiss the ring"
requirement: D-04
verification:
- kind: unit
ref: "../sm-demo-app/vue-demo-app/app/utils/communityAuth.test.ts (createChatAuth with a sign-out, createCommunitySignOut, endSession)"
status: pass
- kind: other
ref: "pnpm --dir ../sm-demo-app/vue-demo-app run build on the v0.1.0 pins"
status: pass
duration: 14min
completed: 2026-11-02
---
# Phase 20 Plan 08: Call and session fixes Summary
**Leaving a call is now final at any await of the attempt (per-attempt token with stale checks), a failed connect ends an empty session from GET status, the call page asks before media unless the call button or Accept handed over in memory, the chat renders deceptive link hosts as text through `honestHttpUrl`, and logout resets the chat, the realtime socket and the ring in the same tab.**
## Performance
- **Duration:** about 14 min
- **Started:** 2026-11-02T17:25:39Z
- **Completed:** 2026-11-02T17:39:00Z
- **Tasks:** 5 of 5
- **Files modified:** 20 (2 created) across three repositories
## Commits per repository
Nothing was pushed or tagged. vue-demo-app keeps its v0.1.0 layer pins. The sm-demo-app gitlink moved but is not staged (` M vue-demo-app`); 20-09 bumps it.
| Repo | Hash | Subject |
|------|------|---------|
| ../vue-chatvideo-layer | 69e8b83 | fix: leaving while a call starts never connects it |
| ../vue-chatvideo-layer | 27ad81c | fix: a call left at any moment stays left, and a failed connect ends an empty session |
| ../vue-chatvideo-layer | 5386903 | feat: the call page asks before it starts the camera and microphone |
| ../vue-chat-layer | a798bb1 | fix: deceptive link hosts render as text, not links or cards |
| ../sm-demo-app/vue-demo-app | 188ae25 | fix: logging out resets the chat, closes the realtime socket and clears a ringing call |
## Test results
| Suite | Before | After |
|-------|--------|-------|
| vue-chatvideo-layer `pnpm test` | 114 | 127 passed (3 files) |
| vue-chat-layer `pnpm test` | 146 | 215 passed (10 files) |
| vue-demo-app `pnpm test` | 218 | 225 passed (18 files) |
- `pnpm --dir ../vue-chatvideo-layer run build:playground` passed. So did `pnpm --dir ../vue-chat-layer run build:playground` and `pnpm --dir ../sm-demo-app/vue-demo-app run build` (v0.1.0 pins). In the built bundle, the plugin calls the resolved `useChat().reset()`, `disconnect()` and `useIncomingCalls().dismiss()`.
- The SPA's copied chat tests (chatEvents, chatFormat, chatText, chatUnread, realtime) are byte-identical to b6d3389 (`git diff --quiet` exit 0).
- No package.json or lockfile changed in any repo. Neither layer names an application (grep count 0).
## New tests per finding
- **CR-01** (useCall.test.ts):
- leave while start is pending: no room, no media, end once the session exists (Task 1, red first)
- unmount while join is pending: no room, no media, leave once the session exists
- leave while connect is pending: no media, the Room disconnected, end once
- a connect that fails after a leave posts nothing more
- leave between camera and microphone: the microphone stays off, the Room disconnected
- a second begin while the first join is pending creates no second Room
- leave while livekit-client loads: no Room after the leave, end once. This one stalls the import itself. `vi.resetModules()` plus `vi.doMock('livekit-client')` with an async factory gated on a deferred, then a fresh import of useCall; the `finally` restores the plain fake. The test is last in the file, and five consecutive runs were stable.
- **WR-05** (useCall.test.ts): a failed connect ends a session nobody else joined; a failed connect leaves when the other side joined; a failed connect with an unreadable status: the starter ends, the callee leaves.
- **Changed expectation:** the old test "a failed LiveKit connect fails the call and leaves the session" is now "a failed LiveKit connect fails the call; an unreadable status ends the starter's session". The stub's `status` answers undefined, which counts as unreadable, so the starter posts `end`, not `leave`. Every other expectation stays.
- **WR-04** (videoClient.test.ts, `describe('call autostart')`):
- a start begins on mount only with the call button's answer, which begin still takes
- a join begins on mount only once, right after Accept
- a start answer does not make a join begin, nor an Accept a start
- **CR-02 client** (urlGuard.test.ts, new):
- `honestHttpUrl` refuses 16 deceptive forms. These are the review's table plus `https:///`, `https:bit.ly`, soft hyphen, ZWSP and `0x7f.1`, along with other schemes and unparseable input.
- It accepts 8 honest forms: a port with query and fragment, an IDN in Unicode and in punycode, a subdomain, upper case, `@` in the path, and `%` in the query.
- `renderChatText` (bare and `[label](url)`), `safeUrl`, `messageUrls` and `linkify` behave on both sets.
- linkEmbeds.test.ts has two new cases.
- **WR-01** (communityAuth.test.ts): unauthorized clears the token, then signs out once. createCommunitySignOut runs its parts in order, keeps going when a part throws, and is safe to call twice. endSession posts then clears then signs out, clears and signs out when the post rejects, and posts nothing without a token.
## For 20-09 (browser checks and release)
- **Layer exports added:** vue-chatvideo-layer `app/lib/videoClient.ts`: `hasStart`, `rememberAccept`, `takeAccept`, `callAutostart`. vue-chat-layer `app/lib/urlGuard.ts`: `honestHttpUrl`. vue-demo-app `app/utils/communityAuth.ts`: `createCommunitySignOut`, `endSession`, and an optional third parameter `onSignedOut` on `createChatAuth`. The plugin provides `$communitySignOut`.
- **Class names:** `.chatvideo-confirm` (the confirmation block), `.chatvideo-confirm__go` (Start call / Join call button), `.chatvideo-confirm__text`, `.chatvideo-confirm__back` (the back link when `backPath` is set).
- **Locale keys:** `chatvideo.confirm.start`, `chatvideo.confirm.join`, `chatvideo.action.startCall` (en "Start call", pl "Rozpocznij rozmowę"), `chatvideo.action.joinCall` (en "Join call", pl "Dołącz do rozmowy").
- **Browser-visible behaviour changes once the pins move:**
- A direct load of `/call/{id}` or `/call/{id}?start=1` shows the confirmation and posts nothing until the click.
- The DM call button (start handoff) and the incoming prompt's Accept still go straight in.
- The callee's status now passes through `starting` before `connecting`; both render "Connecting…".
- Existing browser checks that load `/call/{id}?start=1` directly and expect media must click `.chatvideo-confirm__go` first.
- **Demo call page doc comment** (`vue-demo-app/app/pages/call/[conversation].vue`) still says "With ?start=1 … it starts the call". Per the plan it is updated with the pin bump in 20-09.
- **New layer tags:** each layer needs one (main is ahead of v0.1.0: chatvideo 3 commits, chat 1 commit on top of the 2 already after the tag) before vue-demo-app can pin them.
## Decisions Made
See key-decisions in the frontmatter. In short:
- **Stale answer:** it closes its session only when it is still the current attempt.
- **Leave before connect:** the caller posts end and the callee posts leave, and only once the answer has arrived.
- **WR-05:** the rule follows GET status, with a fallback by role when the status cannot be read.
- **Autostart:** a start needs the waiting start answer; a join takes the Accept marker.
- **Invisible characters:** hosts containing them are refused.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical] Session-channel handle closed when the attempt goes stale while subscribing**
- **Found during:** Task 2
- **Issue:** `openChannel` set `channel` after `await subscribe`. A leave during the subscribe left the handle open (the review's "the channel handle leaks"). A stale attempt's publications could still call `finish`.
- **Fix:** `openChannel(sessionId, stale)` closes the handle when the attempt is stale on arrival, and ignores publications for a stale attempt.
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
- **Commit:** 27ad81c
**2. [Rule 1 - Bug] A failed livekit-client import left the session ringing, like a failed connect**
- **Found during:** Task 2
- **Issue:** WR-05's cause also applied to the import-failure branch, which only disconnected.
- **Fix:** That branch now uses the same `closeFailed` (GET status) rule.
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
- **Commit:** 27ad81c
**3. [Rule 2 - Missing critical] Invisible characters in a Unicode host**
- **Found during:** Task 4
- **Issue:** The plan's Unicode-host branch (NFC, only U+002E separators) accepted `https://bit.l\u00ady/x`, which displays as and opens `bit.ly`. A soft hyphen or ZWSP is dropped by the URL parser.
- **Fix:** `honestHttpUrl` refuses hosts containing `\p{Default_Ignorable_Code_Point}` or `\p{Cf}`. Both forms are under test.
- **Files modified:** ../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts
- **Commit:** a798bb1
**4. [Rule 1 - Bug] leave() before connect no longer ends the caller's call from the callee side**
- **Found during:** Task 1
- **Issue:** The old rule `alone = !room || room.remoteParticipants.size === 0` made a callee who left while connecting post `end`, which ended the waiting caller's call.
- **Fix:** Before `connected`, the caller posts end and the callee leave, as the plan's truth states. Once connected, the room-based last-one-out rule still applies.
- **Files modified:** ../vue-chatvideo-layer/app/composables/useCall.ts
- **Commit:** 69e8b83
### Other notes
- The `leave while livekit-client loads` test stalls the dynamic import itself (the preferred variant in the plan), not the same-tick fallback.
- **Not done here, per the plan:** IN-07 (the handle race between two pending incoming subscribes) stays deferred. The demo's call page comment waits for 20-09.
## Deferred Issues
- `tsc --noEmit -p .nuxt/tsconfig.app.json` in vue-demo-app reports a type error at `app/plugins/auth.ts` on the `fetchJson` line (`$fetch(url, opts)` with `method?: string`). The line was there before this plan (it is line 20 at deb809f) and does not affect the build. Out of scope.
## Known Stubs
None.
## Threat Flags
None. The changes close T-20-42 to T-20-46 and add no new endpoint, auth path or storage.
## Self-Check: PASSED
- FOUND: ../vue-chat-layer/app/lib/urlGuard.ts, ../vue-chat-layer/app/lib/urlGuard.test.ts
- FOUND commits: 69e8b83, 27ad81c, 5386903 (vue-chatvideo-layer), a798bb1 (vue-chat-layer), 188ae25 (vue-demo-app)

View File

@@ -0,0 +1,552 @@
---
phase: 20-demo-instance-with-every-shared-plugin
plan: 09
type: execute
wave: 8
depends_on: ["20-07", "20-08"]
gap_closure: true
review_findings: [CR-01, CR-02, CR-03, WR-01, WR-04, WR-05]
files_modified:
- ../vue-chat-layer/package.json
- ../vue-chatvideo-layer/package.json
- ../sm-demo-app/scripts/check-moderation.sh
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/check-chat.cjs
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/README.md
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../sm-demo-app/plugins/golem15/moderation
- ../sm-demo-app/plugins/golem15/forum
- ../sm-demo-app/plugins/demo/moderation.go
- ../sm-demo-app/moderation_test.go
- ../sm-demo-app/plugins/demo/content/ROADMAP.md
- ../sm-demo-app/vue-demo-app
- ../sm-demo-app/vue-demo-app/package.json
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
- .planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md
- .planning/todos/pending/chatchannels-pin-policy.md
- .planning/todos/pending/moderation-profile-group-and-filenames.md
- .planning/todos/pending/forum-friends-client-401-refresh.md
- .planning/todos/pending/chatvideo-dm-only-host-hook.md
- .planning/todos/pending/chatvideo-live-membership.md
- .planning/todos/pending/check-local-jwt-out-of-argv.md
- .planning/todos/pending/chat-slash-text-escape.md
- .planning/todos/pending/demo-seed-moderation-lists-once.md
- .planning/todos/pending/demo-nginx-livekit-rtc-only.md
- .planning/todos/pending/chatvideo-incoming-handle-leak.md
- .planning/todos/pending/demo-vapid-runbook-pipefail.md
- .planning/todos/pending/demo-chat-members-read-only.md
autonomous: false
requirements: [SC-2, SC-3, SC-4, D-10, D-13, D-17, D-18]
estimate:
tokens: 340000
raw_tokens: 340000
tasks: 12
confidence: low
must_haves:
truths:
- "SC-4: before anything is pushed, the full suites pass in every repository the gap round touched: sm-moderation-plugin and sm-forum-plugin (go vet, go test ./... with Docker), vue-chat-layer and vue-chatvideo-layer (vitest and the playground build), vue-demo-app (vitest and the Nuxt build) and sm-demo-app (go vet, go test ./... with Docker)."
- "Before any tag, the unreleased fixes run together in the local demo (the sibling plugin checkouts through a scratch go.work, the local layers as packed tarballs, both temporary and reverted): check-local moderation, chat, chatlive and callmedia pass, so a one-way tag never ships an untested combination."
- "After the Task 3 go-ahead: annotated tags exist on origin at the default branch's head, each containing the earlier tags. sm-moderation-plugin gets the next patch after v0.1.0 (v0.1.1), sm-forum-plugin after v0.1.1 (v0.1.2), and vue-chat-layer and vue-chatvideo-layer after v0.1.0 (v0.1.1 each). For each, `git ls-remote origin refs/tags/<tag>^{}` equals the pushed HEAD."
- "sm-demo-app requires the new moderation and forum tags (go.mod and go.sum, submodules checked out at the tags, check-pins ok, one `chore: bump` commit per plugin), its forum listener also checks the rendered post (PostSaving.HTML through CheckHTML), and TestDemoModeration refuses every reviewer probe through HTTP on chat send, reply and edit and on forum thread, reply and edit, with nothing stored. vue-demo-app pins both layers at their new tags."
- "check-moderation.sh refuses every reviewer probe in chat and forum with 422 under the right field, after a plain control word is refused (so changed lists cannot pass silently), and leaves nothing behind. check-chat.cjs --call shows the confirmation on a direct /call load with no LiveKit connection before the click, then completes the DM call. check-chat.cjs's default mode shows that logout closes the realtime socket."
- "SC-3, after the Task 7 go-ahead: rome runs the new binary and site through DEPLOY.md's Upload and Release. Only summercms-demo and summercms-demo-site restart, check-live prints `check-live: ok`, and bin/demo.prev and site/.output.prev are kept for a rollback."
- "SC-2, D-17 on the live site: with the user's live tokens, the bypass probe passes against https://demo.summercms.io and the call probe completes a DM call there with the confirmation step. Without tokens the user's own UAT of the same steps is recorded. smoke.sh passes on the deployed artifact."
- "After the Task 11 go-ahead, one post per plugin tag (sm-moderation-plugin, sm-forum-plugin) is on https://summercms.io/changelog/plugins, written per CLAUDE.md (user-facing, no planning IDs, no H1). The journal token is never printed or copied."
- "20-REVIEW-DISPOSITION.md: CR-01, CR-02, CR-03, WR-01, WR-04 and WR-05 are fixed, with commit refs. WR-02, WR-03, WR-06 and IN-01 to IN-09 are deferred, each pointing at its todo in .planning/todos/pending/ (g15office task id in the frontmatter when the g15office tools answer)."
artifacts:
- path: "../sm-demo-app/scripts/check-moderation.sh"
provides: "the bypass probe for a local or the live demo"
contains: "check-moderation: ok"
- path: "../sm-demo-app/plugins/demo/moderation.go"
provides: "forum listener checking the rendered post"
contains: "CheckHTML"
- path: ".planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md"
provides: "the closed ledger"
contains: "fixed"
key_links:
- from: "../sm-demo-app/plugins/demo/moderation.go"
to: "../sm-forum-plugin/events/events.go"
via: "checkPost runs Filter.CheckHTML on ev.HTML after title and body"
pattern: "ev\\.HTML"
- from: "../sm-demo-app/go.mod"
to: "../sm-moderation-plugin"
via: "require at the new patch tag"
pattern: "sm-moderation-plugin v0\\.1\\.[1-9]"
- from: "../sm-demo-app/vue-demo-app/package.json"
to: "../vue-chatvideo-layer"
via: "git+https pin at the new tag"
pattern: "vue-chatvideo-layer.git#v0\\.1\\.[1-9]"
prohibitions:
- statement: "No push, tag, rome change or changelog post happens before its checkpoint is answered; a tag is never moved or re-pushed; tags go on the default branch only."
status: unresolved
verification: judgment
- statement: "No secret value (JWTs, the journal token, .env values) appears in a command line, a log, a committed file or the SUMMARY; .env files are never read; JWTs reach the scripts through 0600 files or the environment and curl gets the header through a config on stdin."
status: unresolved
verification: judgment
- statement: "The temporary integration (scratch go.work, packed layer tarballs) is never committed: vue-demo-app's package.json and pnpm-lock.yaml match their committed state when Task 2 ends."
status: unresolved
verification: automated
- statement: "On rome only summercms-demo and summercms-demo-site restart; nginx, Centrifugo, LiveKit, the firewall and WavePath's programs are untouched."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not in user-story format; no story is invented here): sm-demo-app runs every shared plugin and its public Nuxt site shows them working, deployed publicly with the backend call to action.
This plan's slice (gap round after the code review, plan 9 of 9, wave 8): it releases 20-07's and 20-08's fixes, redeploys the demo and re-checks it live.
- Prove the unreleased fixes together locally.
- Tag the four repositories after a go-ahead.
- Consume the tags in the demo, including the one listener change that needs them.
- Rebuild, redeploy on rome after a go-ahead, and probe the live site for the bypasses and the call flow.
- Publish the two plugin changelog posts after a go-ahead.
- Close 20-REVIEW-DISPOSITION.md.
**Unit tests rule for this gap round:** the project rule "unit tests are the last plan of every phase" is met because every fix in 20-07 and 20-08 carries its own regression tests and this plan runs every touched repository's full suite (Task 2) and the demo's whole gate (Task 6) before anything goes live.
## Decisions taken in this plan (Claude's discretion, recorded)
- **Versions.** Each tag is the next patch after the latest tag, checked on 2026-11-02 with `git tag --sort=-v:refname` and `git ls-remote --tags origin`:
| Repo | Latest tag | New tag |
|---|---|---|
| sm-moderation-plugin | v0.1.0 | v0.1.1 |
| sm-forum-plugin | v0.1.1 | v0.1.2 |
| vue-chat-layer | v0.1.0 | v0.1.1 |
| vue-chatvideo-layer | v0.1.0 | v0.1.1 |
The brief asked for moderation v0.1.2. No v0.1.1 exists, and skipping one would leave a hole in the series, so the plan proposes v0.1.1 and the Task 3 checkpoint shows the table for the user to confirm or override. Task 2 re-reads the tags, because another session could tag in between.
- **Integration before tagging.** Tags are one-way, so Task 1 and Task 2 run the unreleased code together in the local demo. The Go side uses a scratch go.work that maps moderation and forum to the sibling checkouts (GOWORK is respected by dev.sh and build.sh dev). The site side installs the two layers from `pnpm pack` tarballs; unlike link pins, a tarball resolves vue-chat-layer once, as the git tag install will. Both are reverted before the release gate.
- **The demo listener change comes after the bumps** (20-07's finding). `checkPost` gains `CheckHTML(ctx, ev.HTML)` in the commit right after the two `chore: bump` commits, so every sm-demo-app commit builds with its own pointers. Until then, the reviewer's three forum forms are already refused on the raw body by the new Check. The tarball and scratch integration therefore proves those three; the rendered-only forms (bit\.ly, an HTML comment) are pinned by TestDemoModeration in Task 5.
- **Probes written as checks.** The bypass probe is `scripts/check-moderation.sh BASE`, reusable locally (`check-local.sh moderation`) and live. The call probe is check-chat.cjs --call, extended with the direct-load confirmation step, plus a logout step in its default mode. Both read JWTs from a 0600 file or the environment, never argv, and curl gets the bearer header through a config on stdin (the pattern IN-03 asks for). Live, the probe posts only in the visitor's own DM with a persona and as replies on a seeded thread. It starts with a control word that must be refused, and it deletes anything a probe managed to store.
- **Live tokens are an authentication gate.** Live login needs a real Turnstile solve, so Task 9 asks the user to sign in in a browser and save the token cookie values into two files. The alternative is "manual": the user runs the same steps by hand.
- **Changelog posts** go to sm-moderation-plugin and sm-forum-plugin only. The layers have no plugin.yaml and no changelog stream, and changelog-post.py refuses a repository without a manifest.
- **Deferred findings** (WR-02, WR-03, WR-06, IN-01 to IN-09) each become one todo in the existing format. IN-07's logout half was fixed by 20-08 (dismiss on sign-out); its todo keeps the handle leak.
- spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip).
<objective>
Release the gap-round fixes, put them on the live demo, and prove there that the bypasses are closed and the call flow asks before media; close the review ledger.
Purpose: SC-2, SC-3 and SC-4 hold again after the code review; D-17 (moderation), D-18 (the seams) and D-13 (the call page) hold on the live site.
Output: four tags, commits in sm-demo-app and vue-demo-app, a new release on rome, two changelog posts, the closed disposition ledger and twelve follow-up todos, each step after its go-ahead.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-05-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-06-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-07-SUMMARY.md
@.planning/phases/20-demo-instance-with-every-shared-plugin/20-08-SUMMARY.md
Repos: ../sm-moderation-plugin, ../sm-forum-plugin, ../vue-chat-layer, ../vue-chatvideo-layer (each on main, 20-07 and 20-08 commits unpushed); ../sm-demo-app (master; submodules plugins/golem15/* at their required tags; vue-demo-app submodule with 20-08's commit, gitlink moved but not committed); ../sm-summercmsio-app (scripts/changelog-post.py). Host: rome over `ssh -p 2222 debian@10.8.3.118` (passwordless sudo; the `rome` alias does not resolve here), with `RSH="ssh -p 2222"` and `ROME=debian@10.8.3.118` as DEPLOY.md uses them.
Rules (CLAUDE.md and the brief): no co-author tags; stage files by explicit path; one logical change per commit; planning docs and code in separate commits; the plugin or layer is pushed and tagged first, then the pointer or pin bump in the app as its own commit (one `chore: bump <name> to <tag>` per plugin); push vue-demo-app before the sm-demo-app commit that moves its gitlink; tags only on the default branch, annotated, never moved; never read `.env*` files; never print, copy or mint the journal token; leave other sessions' uncommitted edits (STATE.md, state.json, G15OFFICE.md) alone; Docker tests through `sudo -u golem` if the socket is denied. After Task 4, write a progress note into a draft 20-09-SUMMARY.md (tasks done, tags and commits, open points), so a context reset resumes cleanly. `SCRATCH` in the commands below is this session's scratchpad directory; set it once (`SCRATCH=<the scratchpad path>`) before Task 1 and again after a context reset.
<interfaces>
sm-demo-app scripts: dev.sh up (Postgres, Centrifugo, LiveKit 127.0.0.1:7890, Go on :8097, Nuxt on :3020; builds through go.work, so GOWORK overrides it); check-local.sh CHECK... (helpers request, json_has, bad, visitor_jwt, throwaway_user, psql_value; ALL_CHECKS; check_call, two_member_chat, check_chatlive, check_callmedia); check-chat.cjs --base URL [--two|--call] (DEMO_JWT, DEMO_JWT2 from the environment; TOKEN_COOKIE summercms_demo_token; the call mode clicks .chatvideo-callbutton__btn, accepts on .chatvideo-incoming, waits for remote video, mutes, both click .chatvideo-button--leave, polls video/status); check-all.sh [--local] (step 5 runs check-local.sh back remote chatlive callmedia); check-pins.sh; build.sh [release|dev] (release refuses dirty trees, a stale ROADMAP snapshot and pins off go.mod); sync-roadmap.sh; smoke.sh [binary]; check-deploy.sh; check-live.sh URL.
Demo APIs used by the probe: GET /_demo/api/v1/chat/members (persona Ada Hart); POST /_chat/api/v1/contexts/demo/1/dm {user_id}; POST /_chat/api/v1/conversations/{id}/messages {content}; DELETE /_chat/api/v1/messages/{id}; GET /_forum/api/v1/contexts/demo/1/categories and /threads; POST /_forum/api/v1/contexts/demo/1/categories/{categoryId}/threads {title, body}; POST /_forum/api/v1/contexts/demo/1/threads/{threadId}/posts {body}; DELETE /_forum/api/v1/contexts/demo/1/posts/{postId} and /threads/{threadId}. Refusal: 422 {"error":"Validation failed","errors":{field:[message]}} with field content (chat), title or body (forum).
Live default lists (demo:seed, unless an admin changed them): words "buy followers", "casino bonus", "free crypto", "viagra"; links "bit.ly".
20-08 names used by the checks: the confirmation block `.chatvideo-confirm`, its button `.chatvideo-confirm__go`; the call button `.chatvideo-callbutton__btn`.
DEPLOY.md: "Build" (build.sh, smoke.sh, check-deploy.sh), "Upload" (three rsync lines with --chown=root:root and sudo rsync), "Release" (migrate and demo:seed with bin/demo.new, keep bin/demo.prev and site/.output.prev, restart summercms-demo and summercms-demo-site, websockets:health), "Verify" (check-live.sh, supervisorctl status, the reset schedule off), "Rollback".
changelog (../sm-summercmsio-app/DEPLOY.md "Posting to the changelog"): `scripts/changelog-post.py source vX.Y.Z --repo ../sm-<name>-plugin`, then `scripts/changelog-post.py publish vX.Y.Z notes.md --excerpt "…" --repo ../sm-<name>-plugin` (`--draft` saves unpublished); the plugin checkout must be exactly at the tag with a clean worktree and a plugin.yaml that passes `summer plugin:manifest --check`; the token is read from ~/.config/summercms/journal-token by the script.
Todo format (.planning/todos/pending/*.md): frontmatter title, date, priority, area, g15office (task id); a short body. .planning/G15OFFICE.md: each todo gets a g15office task in project summercms (type Bug, Feature, Improvement or Task; the description ends with `Local: .planning/todos/pending/<file>`).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the bypass, call-confirmation and logout checks exist, and they pass against the unreleased fixes running together in the local demo</name>
<precondition>The 20-07 and 20-08 SUMMARYs exist and record their commits; `git -C <repo> status --porcelain` is empty in sm-moderation-plugin, sm-forum-plugin, vue-chat-layer and vue-chatvideo-layer; in sm-demo-app it lists at most ` M vue-demo-app`; Docker runs.</precondition>
<files>../sm-demo-app/scripts/check-moderation.sh, ../sm-demo-app/scripts/check-local.sh, ../sm-demo-app/scripts/check-chat.cjs, ../sm-demo-app/scripts/check-all.sh, ../sm-demo-app/README.md, ../sm-demo-app/DEPLOY.md</files>
<read_first>../sm-demo-app/scripts/check-local.sh (header, helpers, visitor_jwt, check_call, two_member_chat, ALL_CHECKS), scripts/check-chat.cjs (whole file), scripts/check-all.sh (step 5), scripts/dev.sh (up, how it builds), go.work, README.md "Checks", DEPLOY.md "Verify", 20-REVIEW.md (the probe forms), 20-07-SUMMARY.md (probe table), 20-08-SUMMARY.md (class names and handoffs)</read_first>
<action>Per D-17, D-13 and D-04.
1. scripts/check-moderation.sh BASE (new, `set -euo pipefail`, header in the style of check-live.sh):
- It reads the member JWT from the file named by DEMO_JWT_FILE (it must be mode 600; a looser mode stops the script) or from DEMO_JWT. It never prints the JWT and never puts it in argv: every request passes the Authorization header to curl as a config on stdin, written with bash builtins.
- It finds the visitor's DM with the persona Ada Hart (members, then dm) and the first seeded forum category and thread.
- Control first: the plain word "viagra" must answer 422 as a chat message (errors.content) and as a forum reply (errors.body). If either control is accepted, it deletes what was stored, prints "check-moderation: the filter is off or the lists differ from the defaults" and exits 1.
- Then every probe from the review, in one array whose comment says it mirrors 20-07's TestMatchBypassWords and TestMatchBypassLinks. Words: viagra with U+200B, with U+00AD; "buy followers" with U+00A0, with U+2009, with U+3000; fullwidth viagra; vi&#97;gra; via**gra**. Links: https://bit%2Ely/x, https://bit.l%79/x, https://bit.ly/x, https://bit。ly/x, https://user@bit.ly/x, [x](https://bit&#46;ly/a). Write each with bash $'\u…' escapes and build the JSON bodies with python3 json.dumps, as check-local does.
- Each probe must answer 422 as a chat message, as a forum reply and once as a forum thread title (errors.title). A probe that answers 2xx is deleted at once (the message, post or thread) and reported.
- It ends with "check-moderation: ok", or one line per failure and exit 1.
2. check-local.sh: `check_moderation` runs check-moderation.sh against $GO with the visitor's JWT through the environment; add `moderation` to ALL_CHECKS and the header list. check-all.sh step 5 runs `moderation` with the other browser and API checks (header comment too).
3. check-chat.cjs:
- Read DEMO_JWT_FILE and DEMO_JWT2_FILE when set (trimmed, never printed), else DEMO_JWT and DEMO_JWT2.
- --call: before the call-button step, A loads /call/{dm}?start=1 directly. `.chatvideo-confirm` must be visible within 5 s. Watch the page's WebSockets for 3 s: none may open to a URL containing /livekit/rtc, and no .chatvideo-tile may render. GET .../video/status must show no session. Then the existing flow runs (the call button and Accept autostart through the handoffs), and the summary line stays "check-chat: ok call".
- Default mode: after the message check, the member clicks Log out in the header. Within 5 s every WebSocket the page opened to /ws must be closed, and /chat must show the register link.
- Update the header comment.
4. README.md "Checks" and DEPLOY.md "Verify": the moderation check, the live bypass probe and the live call probe with token files (the commands, the file mode, that they post only in the visitor's DM and as thread replies and clean up), in the files' style.
5. Commit in sm-demo-app (scripts and docs only): `test: moderation bypass probe, call confirmation and logout checks`.
6. Temporary integration (never committed):
- Write a scratch go.work, $SCRATCH/demo-gap.go.work under this session's scratchpad. It is ../sm-demo-app/go.work with every path made absolute, plugins/golem15/moderation replaced by the absolute path of ../sm-moderation-plugin and plugins/golem15/forum by ../sm-forum-plugin.
- Pack both layers with `pnpm --dir ../vue-chat-layer pack --pack-destination $SCRATCH` (and the same for vue-chatvideo-layer), then `pnpm --dir ../sm-demo-app/vue-demo-app add <the two tarball paths>`.
- Run `GOWORK=$SCRATCH/demo-gap.go.work ../sm-demo-app/scripts/dev.sh up`.
- Run `../sm-demo-app/scripts/check-local.sh moderation chat callmedia` and see them pass. Leave the setup in place for Task 2, which reverts it; say so in the draft SUMMARY if the run stops here.</action>
<verify>
<automated>bash -n ../sm-demo-app/scripts/check-moderation.sh &amp;&amp; node --check ../sm-demo-app/scripts/check-chat.cjs &amp;&amp; ../sm-demo-app/scripts/check-local.sh moderation chat callmedia</automated>
<fails_when>Non-zero exit: a script has a syntax error, or check-local reports moderation failed (a probe stored, a control accepted, a wrong field), chat failed (the message or the logout socket check) or callmedia failed (the direct load opened LiveKit or showed no confirmation, or the DM call did not complete).</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-demo-app show --stat HEAD | grep -cE 'scripts/check-moderation.sh|scripts/check-chat.cjs|scripts/check-local.sh'` prints 3.
- `grep -v '^\s*#' ../sm-demo-app/scripts/check-moderation.sh | grep -cE -- '-H +.?Authorization'` prints 0 (the bearer header never goes on curl's command line).
</acceptance_criteria>
<done>The bypass probe, the call confirmation step and the logout check exist and pass against all four unreleased fixes together, before anything is tagged.</done>
</task>
<task type="auto">
<name>Task 2: Full suites of every touched repository, the rest of the local checks on the unreleased code, revert of the temporary integration, and release prep</name>
<files>../vue-chat-layer/package.json, ../vue-chatvideo-layer/package.json</files>
<read_first>The Task 1 setup ($SCRATCH/demo-gap.go.work, the tarball pins), each repo's README Testing and Changelog sections, ../sm-moderation-plugin/plugin.yaml and ../sm-forum-plugin/plugin.yaml, .planning/notes/core-plugins-own-repos.md, ../sm-demo-app/scripts/check-pins.sh</read_first>
<action>Per SC-4.
1. Full suites (record pass counts in the draft SUMMARY):
- sm-moderation-plugin and sm-forum-plugin: `go -C <repo> vet ./...` and `go -C <repo> test -count=1 ./...` with Docker.
- vue-chat-layer and vue-chatvideo-layer: `pnpm --dir <repo> test` and `pnpm --dir <repo> run build:playground`.
- vue-demo-app: `pnpm test` and `pnpm run build`, first under the tarball pins, then again after the revert below.
- sm-demo-app: `go vet ./...` and `go test -count=1 ./...` under its own go.work (the committed pointers). Also `GOWORK=$SCRATCH/demo-gap.go.work go -C ../sm-demo-app test -count=1 -run '^(TestDemoModeration|TestBootAllPlugins|TestGoModRequiresTags)$' .`, which shows the new plugin code works with the demo before it is tagged. TestGoModRequiresTags only reads go.mod, so it must still pass.
2. Remaining local checks on the unreleased code: `../sm-demo-app/scripts/check-local.sh chatlive call forum friends herald tour`.
3. Revert the temporary integration: `git -C ../sm-demo-app/vue-demo-app checkout -- package.json pnpm-lock.yaml`, then `pnpm --dir ../sm-demo-app/vue-demo-app install --frozen-lockfile`. Restart the stack without the GOWORK override (`../sm-demo-app/scripts/dev.sh up` restarts or rebuilds per its own rules; stop the old Go process if dev.sh does not). Delete the scratch tarballs and go.work.
4. Release prep, recorded as a table in the draft SUMMARY:
- For each of the four repos: the latest tag (`git tag --sort=-v:refname | head -1` and `git ls-remote --tags origin`) and the next patch tag (expected v0.1.1, v0.1.2, v0.1.1, v0.1.1).
- The default branch (`git remote show origin`, HEAD branch main), a fast-forward of origin main (`git merge-base --is-ancestor origin/main HEAD` after `git fetch`), and every earlier tag an ancestor of HEAD.
- The README changelog entries for the new versions (moderation, forum), and `summer plugin:manifest --check` passing in both plugins (summer from PATH, else `go -C ../summercms.go run ./cmd/summer`).
- Set "version" in the two layers' package.json to the new version, and commit each: `chore: release 0.1.1`.
- The commits each tag will contain (`git log --oneline <latest tag>..HEAD`).</action>
<verify>
<automated>for r in sm-moderation-plugin sm-forum-plugin; do go -C ../$r vet ./... &amp;&amp; go -C ../$r test -count=1 ./... || exit 1; done &amp;&amp; for l in vue-chat-layer vue-chatvideo-layer; do pnpm --dir ../$l test || exit 1; done &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app run build &amp;&amp; go -C ../sm-demo-app vet ./... &amp;&amp; go -C ../sm-demo-app test -count=1 ./... &amp;&amp; git -C ../sm-demo-app/vue-demo-app diff --quiet -- package.json pnpm-lock.yaml &amp;&amp; for l in vue-chat-layer vue-chatvideo-layer; do node -e "process.exit(require('../$l/package.json').version === '0.1.1' ? 0 : 1)" || exit 1; done</automated>
<fails_when>Non-zero exit: a suite fails in any touched repository, the temporary pins were not reverted, or a layer's package.json version was not set.</fails_when>
</verify>
<acceptance_criteria>
- The draft SUMMARY holds the release table (repo, latest tag, new tag, HEAD, commits since the tag) and the suite results.
- `git -C ../sm-demo-app status --porcelain` lists at most ` M vue-demo-app`.
</acceptance_criteria>
<done>Every touched repository is green on its own and together with the others, the integration left no trace, and the release table is ready for the go-ahead.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 3: Go-ahead to push main and tag the four repositories</name>
<read_first>The Task 2 release table (repo, latest tag, proposed tag, HEAD, commits since the latest tag), the suite results and the local checks, the note on moderation's version (v0.1.1, not the brief's v0.1.2)</read_first>
<acceptance_criteria>
- The user's answer is recorded in the SUMMARY with the confirmed versions.
</acceptance_criteria>
<decision>Push main and create the annotated tags sm-moderation-plugin v0.1.1, sm-forum-plugin v0.1.2, vue-chat-layer v0.1.1 and vue-chatvideo-layer v0.1.1?</decision>
<context>Tags are one-way: consumers' go.sum and lockfiles pin them, so they are never moved, only superseded. Each tag carries only the gap-round fixes listed in the table (plus vue-chat-layer's two test and refactor commits already on main since v0.1.0). The demo can consume them only after this. Other applications pin older tags and are unaffected, because the changes are additive (PostSaving gains a field, the filter matches more forms, the layers add a guard, handoffs and a confirmation).</context>
<options>
<option id="release">
<name>Push and tag all four</name>
<pros>The demo can pin the fixes and be redeployed.</pros>
<cons>The versions are public and final.</cons>
</option>
<option id="push-only">
<name>Push main, no tags yet</name>
<pros>The commits are backed up and visible for review.</pros>
<cons>The demo cannot pin them; the plan stops after Task 4 until tags are approved.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>Nothing leaves this machine.</pros>
<cons>The live demo keeps the bypasses and the call race.</cons>
</option>
</options>
<resume-signal>Reply "release" (optionally with other versions), "push-only" or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 4: Push the four repositories and tag them</name>
<reversibility rating="one-way">Pushed tags are pinned by consumers' go.sum and lockfiles; they can be superseded but never moved.</reversibility>
<precondition>Task 3 answered "release" or "push-only".</precondition>
<files>(no files; git refs on origin)</files>
<read_first>The Task 3 answer, the Task 2 release table, .planning/notes/core-plugins-own-repos.md</read_first>
<action>For each of sm-moderation-plugin, sm-forum-plugin, vue-chat-layer and vue-chatvideo-layer, in that order:
- `git fetch origin` and confirm origin/main is still an ancestor of HEAD (stop and report if another session pushed);
- `git push origin main` and check `git ls-remote origin refs/heads/main` equals HEAD;
- with "release": create the annotated tag at HEAD (`git tag -a <tag> -m "<one line: what the release fixes>"`), push only that tag (`git push origin <tag>`), check `git ls-remote origin "refs/tags/<tag>^{}"` equals HEAD and that the previous tag is an ancestor (`git merge-base --is-ancestor <previous> <tag>`).
With "push-only", stop after the pushes, record it, and end the plan run here (resume at Task 3 when the user approves tags).
Write the progress note into the draft 20-09-SUMMARY.md: the four pushed heads and tags.</action>
<verify>
<automated>for r in "sm-moderation-plugin v0.1.1" "sm-forum-plugin v0.1.2" "vue-chat-layer v0.1.1" "vue-chatvideo-layer v0.1.1"; do set -- $r; [ "$(git -C ../$1 ls-remote origin refs/heads/main | cut -f1)" = "$(git -C ../$1 rev-parse HEAD)" ] || exit 1; [ "$(git -C ../$1 ls-remote origin "refs/tags/$2^{}" | cut -f1)" = "$(git -C ../$1 rev-parse HEAD)" ] || exit 1; done</automated>
<fails_when>Non-zero exit: a remote main differs from the local HEAD, or a tag is missing on origin or points elsewhere. Use the versions confirmed in Task 3 if they differ from the table; with "push-only" only the main check applies.</fails_when>
</verify>
<acceptance_criteria>
- `git -C ../sm-moderation-plugin describe --exact-match HEAD` and the same in the other three repos print the new tag.
</acceptance_criteria>
<done>The four fixes are public at their patch tags on the default branch.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: The demo consumes the tags: layer pins, plugin bumps, the forum listener reads the rendered post, and the gitlink</name>
<precondition>Task 4 created all four tags on origin.</precondition>
<files>../sm-demo-app/vue-demo-app/package.json, ../sm-demo-app/vue-demo-app/pnpm-lock.yaml, ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/plugins/golem15/moderation, ../sm-demo-app/plugins/golem15/forum, ../sm-demo-app/plugins/demo/moderation.go, ../sm-demo-app/moderation_test.go, ../sm-demo-app/vue-demo-app, ../sm-demo-app/plugins/demo/content/ROADMAP.md</files>
<read_first>../sm-demo-app/plugins/demo/moderation.go, moderation_test.go (TestDemoModeration: setLists, post, put, refused, the stored-count check), go.mod, .gitmodules, scripts/check-pins.sh, scripts/build.sh (the release refusals), scripts/sync-roadmap.sh, vue-demo-app/package.json, vue-demo-app/app/pages/call/[conversation].vue, 20-07-SUMMARY.md (the listener finding), ../sm-moderation-plugin/README.md (CheckHTML), ../sm-forum-plugin/README.md (PostSaving.HTML)</read_first>
<behavior>
- TestDemoModeration, with the lists set to the demo defaults plus the path rule example.com/promo: every probe of check-moderation.sh, the path-rule forms (https://example.com:443/promo, https://example.com//promo, https://sub.example.com/promo) and the decomposed żółw case (with żółw added to the words), is refused with 422 on chat send, reply and edit (errors.content) and on forum thread title, thread body, reply and edit (errors.title or errors.body); nothing is stored.
- The rendered-only forum forms bit\.ly and via<!-- x -->gra are refused on reply and edit with errors.body (only CheckHTML on ev.HTML catches them).
- The existing TestDemoModeration cases keep passing.
</behavior>
<action>Per D-17, D-18 and D-13, in this commit order:
1. vue-demo-app: `pnpm --dir ../sm-demo-app/vue-demo-app add` the two git+https specs at the new tags (vue-chat-layer and vue-chatvideo-layer). Update the doc comment of app/pages/call/[conversation].vue: ?start=1 picks Start over Join on the confirmation; media starts only after a click there, the call button or Accept. Run `pnpm test` and `pnpm run build`, then commit in vue-demo-app: `chore: pin vue-chat-layer and vue-chatvideo-layer v0.1.1`.
2. sm-demo-app, for moderation and then forum: fetch tags in the submodule and check it out at the new tag; `GOWORK=off GOPRIVATE=git.golem15.com/* go -C ../sm-demo-app get git.golem15.com/golem15/sm-<name>-plugin@<tag>` and `GOWORK=off GOPRIVATE=git.golem15.com/* go -C ../sm-demo-app mod tidy` (GOPRIVATE as build.sh sets it); commit go.mod, go.sum and the gitlink: `chore: bump sm-moderation-plugin to v0.1.1`, then `chore: bump sm-forum-plugin to v0.1.2`.
3. plugins/demo/moderation.go: checkPost also runs `Filter.CheckHTML` on `ev.HTML` (when not empty), after title and body, and answers a violation as `Rejection{Field: "body"}`; update the function and file comments (chat messages are checked by Check, which normalises; forum posts also by what they render to). Extend TestDemoModeration per the behavior block (red first for the rendered-only forms). Commit: `fix: refuse forum posts whose rendered text carries a banned word or link`.
4. Commit the vue-demo-app gitlink in sm-demo-app: `chore: bump vue-demo-app (layers v0.1.1, sign-out reset)`.
5. If `scripts/sync-roadmap.sh --check` reports a stale snapshot, run `scripts/sync-roadmap.sh` and commit `chore: sync the roadmap snapshot`.
Nothing is pushed in this task.</action>
<verify>
<automated>go -C ../sm-demo-app vet ./... &amp;&amp; go -C ../sm-demo-app test -count=1 ./... &amp;&amp; go -C ../sm-demo-app test -count=1 -run '^(TestDemoModeration|TestGoModRequiresTags)$' -v . 2>&amp;1 | grep -cE -- '^--- PASS: Test(DemoModeration|GoModRequiresTags) ' | grep -qx 2 &amp;&amp; ../sm-demo-app/scripts/check-pins.sh &amp;&amp; pnpm --dir ../sm-demo-app/vue-demo-app test</automated>
<fails_when>Non-zero exit: vet or a Go test fails (a probe stored, a rendered-only form accepted, a go.mod require not a tag), check-pins reports a checkout off its required tag, or the site tests fail on the new pins.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE 'sm-moderation-plugin v0\.1\.1$|sm-forum-plugin v0\.1\.2$' ../sm-demo-app/go.mod` prints 2.
- `grep -cE 'vue-chat(video)?-layer\.git#v0\.1\.1' ../sm-demo-app/vue-demo-app/package.json` prints 2.
- `git -C ../sm-demo-app status --porcelain` prints nothing.
</acceptance_criteria>
<done>The demo builds from the released fixes only, and its own listener refuses forum posts by what they render to.</done>
</task>
<task type="auto">
<name>Task 6: The demo's whole gate passes locally on the released versions, including a release build and the smoke run</name>
<files>(no files; build outputs bin/demo and vue-demo-app/.output, untracked)</files>
<read_first>../sm-demo-app/scripts/check-all.sh (steps and --local), DEPLOY.md "Build", 20-06-SUMMARY.md (the last check-all run)</read_first>
<action>Run `../sm-demo-app/scripts/dev.sh up` (the stack now builds from the bumped submodules through go.work), then `../sm-demo-app/scripts/check-all.sh --local`. It runs the app and plugin suites, vitest in vue-demo-app and the four layers, sync-roadmap --check, check-pins, `build.sh release`, smoke.sh, check-deploy, then check-local back remote chatlive callmedia moderation and check-live against the current live site. Record the release id from the `build: ok release <id>` line and each step's result in the draft SUMMARY. If a step fails, fix the cause in the repository that owns it. A fix to a tagged repository means a new patch tag through Task 3's gate again, never a moved tag.</action>
<verify>
<automated>../sm-demo-app/scripts/check-all.sh --local 2>&amp;1 | tail -1 | grep -qx 'check-all: ok'</automated>
<fails_when>Non-zero exit: any check-all step fails (a suite, the snapshot, pins, the release build, smoke, check-deploy, a browser check or check-live).</fails_when>
</verify>
<acceptance_criteria>
- The draft SUMMARY records `build: ok release <id>` and `smoke: ok` from this run.
</acceptance_criteria>
<done>The exact release that would go to rome passed the whole gate on this machine.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 7: Go-ahead to push the demo repositories and deploy the release to rome</name>
<read_first>The release id from Task 6 and the check-all result; `git -C ../sm-demo-app log --oneline origin/master..HEAD` and the same for vue-demo-app; the list of what changes on rome: binary, site, config rsync; `migrate` (no new migrations in the bumped plugins: check `git -C <plugin> diff --stat <previous tag> <new tag> -- updates/`) and `demo:seed`; restart of summercms-demo and summercms-demo-site only; no nginx, Centrifugo, LiveKit, firewall or WavePath change; the rollback commands from DEPLOY.md "Rollback"</read_first>
<acceptance_criteria>
- The user's answer is recorded in the SUMMARY.
</acceptance_criteria>
<decision>Push vue-demo-app and sm-demo-app and deploy release <id> to demo.summercms.io?</decision>
<context>The live demo still has CR-01 (calls connect after Leave), CR-02 and CR-03 (filter bypasses), WR-01, WR-04 and WR-05. The release restarts only the demo's own two programs; bin/demo.prev and site/.output.prev stay for a rollback.</context>
<options>
<option id="deploy">
<name>Push and deploy</name>
<pros>The fixes are live today.</pros>
<cons>A short restart of the demo's API and site.</cons>
</option>
<option id="push-only">
<name>Push, deploy later</name>
<pros>The pins are backed up.</pros>
<cons>The live demo keeps the bugs; Tasks 8 to 10 wait.</cons>
</option>
<option id="hold">
<name>Hold</name>
<pros>No change anywhere.</pros>
<cons>As above, and the pins exist only here.</cons>
</option>
</options>
<resume-signal>Reply "deploy", "push-only" or "hold".</resume-signal>
</task>
<task type="auto">
<name>Task 8: Push the demo, upload and release on rome by the runbook, and check the live site</name>
<reversibility rating="costly">A release can be rolled back with bin/demo.prev and site/.output.prev (DEPLOY.md "Rollback"); migrations, if any ran, are not reversed.</reversibility>
<precondition>Task 7 answered "deploy" (with "push-only" do steps 1 and 2 only and stop).</precondition>
<files>(no repository files; rome: /srv/summercms-demo/bin, /srv/summercms-demo/site, /srv/summercms-demo/config)</files>
<read_first>../sm-demo-app/DEPLOY.md "Upload", "Release", "Verify", "Rollback"; 20-05-SUMMARY.md (how the last release went, shared-host rules)</read_first>
<action>Per SC-3 and D-10.
1. Push vue-demo-app master, check `git ls-remote` equals HEAD; then push sm-demo-app master, check the same.
2. With "push-only", record and stop here.
3. Build machine: DEPLOY.md "Upload", the three rsync lines exactly (config without overrides.yaml, bin/demo to bin/demo.new, vue-demo-app/.output/ to site/.output.new/, `--chown=root:root`, sudo rsync, `$RSH`), from the release built in Task 6 (re-run `scripts/build.sh` if anything changed since, then smoke.sh again).
4. rome: DEPLOY.md "Release", exactly as written, in this order:
- migrate and demo:seed with bin/demo.new as summercms-demo with SUMMER_ENV=production;
- keep bin/demo.prev and site/.output.prev, then swap in the new binary and site;
- `sudo supervisorctl restart summercms-demo summercms-demo-site`;
- websockets:health answers Configuration OK.
Restart nothing else. Every command that could carry a secret runs on rome without echoing it.
5. DEPLOY.md "Verify":
- `scripts/check-live.sh https://demo.summercms.io` prints `check-live: ok`;
- supervisorctl status shows the four demo programs RUNNING;
- the reset schedule stays off;
- `wavepath-livekit` keeps its pid (read-only check).
6. Record in the draft SUMMARY: the release id, the commands' results, the pids before and after, and the rollback state.</action>
<verify>
<automated>[ "$(git -C ../sm-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app rev-parse HEAD)" ] &amp;&amp; [ "$(git -C ../sm-demo-app/vue-demo-app ls-remote origin refs/heads/master | cut -f1)" = "$(git -C ../sm-demo-app/vue-demo-app rev-parse HEAD)" ] &amp;&amp; ../sm-demo-app/scripts/check-live.sh https://demo.summercms.io</automated>
<fails_when>Non-zero exit: a remote master differs from the local HEAD, or check-live reports any failure after the release (roll back per DEPLOY.md and report if the site or API is broken).</fails_when>
</verify>
<acceptance_criteria>
- `ssh -p 2222 debian@10.8.3.118 sudo supervisorctl status summercms-demo summercms-demo-site summercms-demo-centrifugo summercms-demo-livekit` shows four RUNNING lines.
</acceptance_criteria>
<done>demo.summercms.io runs the release with all six fixes, and its anonymous checks pass.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 9: The user signs in on the live demo so the probes can act as members (an authentication gate: live login needs a real Turnstile solve)</name>
<action>Sign in on https://demo.summercms.io in a browser and save the session token for the probes. Claude cannot do this because live login is behind Turnstile.</action>
<instructions>
Claude already deployed the release and ran the anonymous live checks. The member probes need a signed-in session:
1. In a browser, log in at https://demo.summercms.io/login as the demo's visitor account (or any member account you use for checks).
2. In the browser's developer tools (Application, then Cookies), copy the value of the cookie `summercms_demo_token`.
3. Save it in a file only you can read, without pasting it into this chat: `install -m 600 /dev/null ~/.config/summercms/demo-live-jwt`, then paste the value into that file with an editor.
4. For the two-browser call probe, do the same with a second member account (register one in a private window if needed) into `~/.config/summercms/demo-live-jwt2`.
Alternatively run the checks yourself ("manual"):
- Send each probe from DEPLOY.md "Verify" as a DM to Ada and as a forum reply. Each must be refused, and the plain word viagra too.
- Open /call/{your DM with Ada}?start=1 directly. A Start call button must show, and the camera must stay off until you click it.
- From a second browser, call yourself from the DM button and accept. Both sides must connect.
- Leave while the page still says "Connecting…". The camera light must go off and the other side must stop ringing.
- Log out while a call rings. The prompt and the chat panel must go away.
</instructions>
<verification>Claude checks that each file exists with mode 600 and is not empty (`stat -c %a`, `test -s`), without reading its content into the conversation.</verification>
<resume-signal>Reply "tokens ready" (both files), "one token" (only demo-live-jwt; the call probe is then your manual check) or "manual approved" / "manual failed: what you saw".</resume-signal>
</task>
<task type="auto">
<name>Task 10: Live recheck: the bypass probe, the call probe, check-live and smoke, then the changelog drafts</name>
<precondition>Task 9 answered; for the automated probes the token files exist with mode 600.</precondition>
<files>(no repository files; drafts in this session's scratchpad)</files>
<read_first>The Task 9 answer, ../sm-demo-app/DEPLOY.md "Verify" (the probe commands from Task 1), ../sm-summercmsio-app/DEPLOY.md "Posting to the changelog", the CLAUDE.md changelog rules</read_first>
<action>Per SC-2, D-17 and D-13.
1. With a token: `DEMO_JWT_FILE=~/.config/summercms/demo-live-jwt ../sm-demo-app/scripts/check-moderation.sh https://demo.summercms.io` prints `check-moderation: ok`.
2. With two tokens: `DEMO_JWT_FILE=~/.config/summercms/demo-live-jwt DEMO_JWT2_FILE=~/.config/summercms/demo-live-jwt2 NODE_PATH="$(npm root -g)" node ../sm-demo-app/scripts/check-chat.cjs --base https://demo.summercms.io --call` prints `check-chat: ok call`. Fake media goes through rome's LiveKit; the direct-load step shows the confirmation. Do not run the default mode live: it would post in public #general and log the token out.
3. With "manual approved", record the user's answer instead of the automated probe it replaces; with "manual failed", stop and report what the user saw against the fix it points to.
4. `../sm-demo-app/scripts/check-live.sh https://demo.summercms.io` and `../sm-demo-app/scripts/smoke.sh` (the deployed artifact bin/demo) again; both ok.
5. Remove the two token files (`rm -f`) and tell the user their browser sessions stay signed in.
6. Changelog drafts. From ../sm-summercmsio-app run `scripts/changelog-post.py source <tag> --repo ../sm-moderation-plugin`, and the same for sm-forum-plugin. Write the user-facing notes to $SCRATCH/changelog-moderation.md and $SCRATCH/changelog-forum.md, using sections `## Highlights`, `## Added`, `## Changed` and `## Fixed` as needed. Include no H1, no planning IDs and no `quick-*` codes, and no consuming application's name. Add a one-line plain-text excerpt for each.
- Moderation: banned words and links now match what readers see (invisible characters, Unicode spaces, diacritics, entities, inline markup, URL forms browsers normalise); path entries cover subdomains; MatchHTML and Filter.CheckHTML for rendered content.
- Forum: PostSaving carries the rendered post HTML.
7. Record every result in the draft SUMMARY.</action>
<verify>
<automated>../sm-demo-app/scripts/check-live.sh https://demo.summercms.io &amp;&amp; test -s "$SCRATCH/changelog-moderation.md" &amp;&amp; test -s "$SCRATCH/changelog-forum.md" &amp;&amp; ! grep -qE '^# |CR-0|WR-0|IN-0|20-0[0-9]|quick-' "$SCRATCH/changelog-moderation.md" "$SCRATCH/changelog-forum.md"</automated>
<fails_when>Non-zero exit: check-live fails, a draft is missing, or a draft has an H1, a review or plan ID or a quick code. The probe runs of steps 1 and 2 are recorded with their output line; a probe failure stops the plan with a report, which is not a verify failure.</fails_when>
</verify>
<acceptance_criteria>
- The draft SUMMARY records `check-moderation: ok` and `check-chat: ok call` against https://demo.summercms.io, or the user's manual answer for each.
- `test -e ~/.config/summercms/demo-live-jwt || echo gone` prints gone.
</acceptance_criteria>
<done>The live demo refuses every bypass from the review, asks before a call page turns media on, completes a DM call, and passes its anonymous checks and smoke run.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 11: Go-ahead for the two plugin changelog posts</name>
<read_first>$SCRATCH/changelog-moderation.md and $SCRATCH/changelog-forum.md with their excerpts, the tags (sm-moderation-plugin v0.1.1, sm-forum-plugin v0.1.2), and whether ~/.config/summercms/journal-token exists (`test -s`, never read)</read_first>
<acceptance_criteria>
- The user's answer is recorded in the SUMMARY, with any wording changes applied to the drafts.
</acceptance_criteria>
<decision>Publish the two plugin posts on https://summercms.io/changelog/plugins?</decision>
<context>CLAUDE.md: plugin release tags get a post on /changelog/plugins the same day. The posts are public and can be updated later with --update. The layers get no post (no plugin.yaml, no changelog stream).</context>
<options>
<option id="publish">
<name>Publish both</name>
<pros>The releases are announced the day they are tagged.</pros>
<cons>Public at once.</cons>
</option>
<option id="draft">
<name>Save as drafts (--draft)</name>
<pros>The text can be reviewed in the admin first.</pros>
<cons>Not announced until published.</cons>
</option>
<option id="skip">
<name>Skip</name>
<pros>No post.</pros>
<cons>Breaks the same-day rule.</cons>
</option>
</options>
<resume-signal>Reply "publish", "draft" or "skip", with any changes to the text.</resume-signal>
</task>
<task type="auto">
<name>Task 12: Publish the posts as answered, and close the review ledger with commit refs and follow-up todos</name>
<precondition>Task 11 answered.</precondition>
<files>.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md, .planning/todos/pending/chatchannels-pin-policy.md, .planning/todos/pending/moderation-profile-group-and-filenames.md, .planning/todos/pending/forum-friends-client-401-refresh.md, .planning/todos/pending/chatvideo-dm-only-host-hook.md, .planning/todos/pending/chatvideo-live-membership.md, .planning/todos/pending/check-local-jwt-out-of-argv.md, .planning/todos/pending/chat-slash-text-escape.md, .planning/todos/pending/demo-seed-moderation-lists-once.md, .planning/todos/pending/demo-nginx-livekit-rtc-only.md, .planning/todos/pending/chatvideo-incoming-handle-leak.md, .planning/todos/pending/demo-vapid-runbook-pipefail.md, .planning/todos/pending/demo-chat-members-read-only.md</files>
<read_first>20-REVIEW-DISPOSITION.md, 20-REVIEW.md (WR-02, WR-03, WR-06, IN-01 to IN-09: files, issue, fix), two existing files in .planning/todos/pending/ (format), .planning/G15OFFICE.md (todo mirroring), the commit refs from the 20-07, 20-08 and draft 20-09 SUMMARYs</read_first>
<action>Per SC-4 and the brief.
1. Posts (with "publish" or "draft"):
- From ../sm-summercmsio-app, for each plugin, check the checkout is exactly at its tag with a clean worktree (`git -C ../sm-<name>-plugin describe --exact-match HEAD`; check out the tag if main moved on).
- Run `scripts/changelog-post.py publish <tag> <draft file> --excerpt "<line>" --repo ../sm-<name>-plugin`, adding --draft for "draft". The script reads the journal token itself.
- If the token file is missing or the token is refused, stop and ask the user. Never print, copy or mint a token.
- Check https://summercms.io/changelog/plugins lists the published posts.
- Return the checkouts to main.
2. 20-REVIEW-DISPOSITION.md: CR-01, CR-02, CR-03, WR-01, WR-04 and WR-05 become `fixed`. Each note names the repo, the commit short sha and the tag, for example `sm-moderation-plugin <sha> (v0.1.1)`, plus the live check that confirmed it. WR-02, WR-03, WR-06 and IN-01 to IN-09 become `deferred`, each note pointing at its todo file. Add a `closed: 2026-11-02` line (or the actual date) to the frontmatter.
3. Todos, one per deferred finding, in the existing format: title, date, priority (high for WR-02 and WR-03, medium for WR-06, IN-01, IN-02 and IN-07, low for the rest), area (the repo and file), an empty g15office field until the task exists. The body gives the finding's issue and fix in two or three sentences, with "Source: phase 20 code review (20-REVIEW.md <ID>)". IN-07's todo notes that the logout half was fixed by 20-08. The files are:
- chatchannels-pin-policy.md (WR-02)
- moderation-profile-group-and-filenames.md (WR-03)
- forum-friends-client-401-refresh.md (WR-06)
- chatvideo-dm-only-host-hook.md (IN-01)
- chatvideo-live-membership.md (IN-02)
- check-local-jwt-out-of-argv.md (IN-03)
- chat-slash-text-escape.md (IN-04)
- demo-seed-moderation-lists-once.md (IN-05)
- demo-nginx-livekit-rtc-only.md (IN-06)
- chatvideo-incoming-handle-leak.md (IN-07)
- demo-vapid-runbook-pipefail.md (IN-08)
- demo-chat-members-read-only.md (IN-09)
4. g15office (.planning/G15OFFICE.md):
- Create one task per todo in project summercms (type Bug for WR-02, WR-03, WR-06, IN-02, IN-04, IN-05, IN-07 and IN-09, Improvement for the rest). Each description ends with `Local: .planning/todos/pending/<file>`. Write each task id into its todo's frontmatter.
- Post a short comment on the Phase 20 milestone task: the gap round released and deployed, with the four tags.
- If the g15office tools do not answer, leave the ids empty and list the todos as left for the user in the SUMMARY. Do not edit G15OFFICE.md itself.
5. Commit in summercms.go by explicit path (planning docs only; STATE.md, state.json and G15OFFICE.md untouched): `docs(20): review disposition after the gap closure`.</action>
<verify>
<automated>D=.planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md; for f in CR-01 CR-02 CR-03 WR-01 WR-04 WR-05; do grep -E "^\| $f \|" $D | grep -q '| fixed |' || exit 1; done; for f in WR-02 WR-03 WR-06 IN-01 IN-02 IN-03 IN-04 IN-05 IN-06 IN-07 IN-08 IN-09; do grep -E "^\| $f \|" $D | grep -q '| deferred |' || exit 1; done; ls .planning/todos/pending/{chatchannels-pin-policy,moderation-profile-group-and-filenames,forum-friends-client-401-refresh,chatvideo-dm-only-host-hook,chatvideo-live-membership,check-local-jwt-out-of-argv,chat-slash-text-escape,demo-seed-moderation-lists-once,demo-nginx-livekit-rtc-only,chatvideo-incoming-handle-leak,demo-vapid-runbook-pipefail,demo-chat-members-read-only}.md >/dev/null &amp;&amp; git diff --quiet HEAD -- $D</automated>
<fails_when>Non-zero exit: a finding has the wrong disposition, a todo file is missing, or the ledger change is not committed.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c '| open |' .planning/phases/20-demo-instance-with-every-shared-plugin/20-REVIEW-DISPOSITION.md` prints 0.
- Every fixed row names at least one commit sha and tag.
- The SUMMARY records the post URLs (or the draft ids, or "skipped") and the g15office ids or the note that they are left for the user.
</acceptance_criteria>
<done>The gap round is announced as the user chose, every review finding has its final disposition, and every deferred one is a tracked todo.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| build machine to origin | pushes and one-way tags that other applications pin |
| build machine to rome | rsync and sudo on a shared production host |
| probes to the live demo | member sessions posting test content on a public site |
| build machine to summercms.io | the journal token publishing posts |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-20-47 | Tampering | release tags | high | mitigate | annotated tags on the default branch only after Task 3's go-ahead; fast-forward check before push; ls-remote equals HEAD; earlier tags are ancestors; never moved, a fix gets a new patch tag |
| T-20-48 | Information disclosure | live member JWTs | high | mitigate | the user writes them into 0600 files (never pasted); scripts read the files, never print them, and pass the bearer header to curl as a config on stdin; the files are removed after the probes; the live default mode of check-chat (which would log out and post publicly) is not run |
| T-20-49 | Tampering | probe content on the live demo | medium | mitigate | probes post only in the visitor's own DM and as replies or one thread on seeded content; a control word must be refused first; anything a probe stored is deleted at once and reported |
| T-20-50 | Information disclosure | journal token | high | mitigate | changelog-post.py reads ~/.config/summercms/journal-token itself; it is never printed, copied or minted; a missing or refused token stops the step and asks the user |
| T-20-51 | Denial of service | the shared host during the release | medium | mitigate | DEPLOY.md's release restarts only summercms-demo and summercms-demo-site; WavePath's pid is checked before and after; rollback with bin/demo.prev and site/.output.prev |
| T-20-52 | Tampering | temporary integration leaking into a release | medium | mitigate | the scratch go.work and the tarball pins live outside the repos or are reverted in Task 2 (verify checks package.json and pnpm-lock.yaml are unchanged); build.sh release refuses dirty trees and pins off go.mod |
| T-20-SC | Tampering | npm and Go installs | high | mitigate | only the project's own layers and plugins change version, by git tag; no third-party package is added or bumped; Go tidy runs against tags already on origin |
</threat_model>
<verification>
- Task 1: the three checks pass against the unreleased fixes running together locally.
- Task 2: every touched repository's full suite; the integration reverted; the release table.
- Task 3: go-ahead for push and tags.
- Task 4: remote heads and tags equal local HEAD.
- Task 5: the demo on the tags, the listener on the rendered post, TestDemoModeration and check-pins green.
- Task 6: check-all.sh --local ends with check-all: ok (release build and smoke included).
- Task 7: go-ahead for push and deploy.
- Task 8: pushed heads, the runbook release, check-live ok.
- Task 9: live tokens or the user's manual answer.
- Task 10: bypass probe and call probe against the live site, check-live and smoke again, changelog drafts clean.
- Task 11: go-ahead for the posts.
- Task 12: posts as answered; the ledger closed; twelve todos.
</verification>
<success_criteria>
The six fixed findings are released at patch tags, consumed by the demo, deployed on demo.summercms.io and proven there (bypass probe, call probe, check-live, smoke); the two plugin posts are handled as the user decided; 20-REVIEW-DISPOSITION.md has no open row and every deferred finding is a todo.
</success_criteria>
<output>
Create `.planning/phases/20-demo-instance-with-every-shared-plugin/20-09-SUMMARY.md` when done (it grows from the draft started in Task 2): the release table and tags, every checkpoint answer, the demo commits, the release id and rome results, the live probe results, the post URLs, the ledger changes and the todo list with g15office ids.
</output>

View File

@@ -0,0 +1,147 @@
# Phase 20: Demo instance with every shared plugin - Context
**Gathered:** 2026-10-28
**Status:** Ready for planning
<domain>
## Phase Boundary
Phase 19.4.1 put `sm-demo-app` live on demo.summercms.io with the plugins of that time. This phase adds the chat family to that live instance and shows it working on the public Nuxt site: `chat`, `chatchannels`, `chatvideo`, `chatbots`, `forum`, `herald` and `userfriends` are mounted at tagged versions, seeded by `demo:seed`, deployed on rome, and given site UI through new shared Nuxt layers. Everything decided in 19.4.1 still holds (hosting on rome, own Centrifugo, `/backend` refused by nginx with the contact call to action, open registration behind Turnstile, the reset job, the demo banner, EN/PL, the summercms.io look). This phase does not repeat it.
New shared repos created by this phase: `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer`, `sm-moderation-plugin`.
</domain>
<decisions>
## Implementation Decisions
### Chat frontend
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
### Chat context and access
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
### Video calls
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
### Forum, friends, push
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
### Moderation
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
### Plan-time decisions (2026-10-28, after research)
- **D-20:** Six plans, as in `20-RESEARCH.md` § Suggested Plan Split: 01 shared plugin release (sibling fast-forward, go.mod fixes, chat and forum pre-save seams, `sm-moderation-plugin`, tags); 02 demo backend; 03 `vue-chat-layer` and its use in vue-demo-app; 04 the chatvideo, forum and userfriends layers, pages, tour cards, herald VAPID; 05 rome deploy (LiveKit, Vuurmuur rule as a checkpoint, nginx, `.env`, DEPLOY.md, check-deploy/check-live); 06 unit tests and live verification. 03 and 04 may run in parallel after 02.
- **D-21:** Anonymous visitors on `/forum` see a description and a "register to join" prompt, as on `/chat` (D-04). No public forum read endpoint.
- **D-22:** People-moderation uses a demo moderator user group (bm pattern) whose members can delete messages from the site UI; banning goes through the existing user admin. Chat-family admin screens are deferred.
- **D-23:** chatvideo keeps about 4 participants per room (D-12). Calls are limited to DMs by the UI only: the call button appears only in DM conversations. The server does not refuse channel calls.
### Claude's Discretion
- Seeded channel names and persona users, and how much EN/PL history to seed.
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
- How `vue-chat-layer` exposes extension points for app-specific embeds.
### Folded Todos
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Prior demo decisions
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/19.4.1-CONTEXT.md` — hosting, accounts, reset job, banner, tour, EN/PL, look, Centrifugo, tagged-module go.mod (D-01..D-23)
- `../sm-demo-app/DEPLOY.md` — rome layout, ports in use, supervisor programs, Centrifugo, release and rollback
- `../sm-demo-app/README.md` — local run, `demo:seed`, credentials handling
- `../sm-demo-app/summer.yaml`, `../sm-demo-app/go.mod` — the current plugin set and tagged requires
### Chat UI source (port)
- `../sm-bm-app/frontend/src/components/chat/` — ChatPanel, MessageList, ChatMessage, ChatComposer, ChatHeader, DmList, MemberPicker, MentionPopup, ReactionBar, EmbedCard, EmbedPicker, StagingChips, DeleteDialog, Avatar
- `../sm-bm-app/frontend/src/stores/chat.ts`, `../sm-bm-app/frontend/src/api/chat.ts`, `../sm-bm-app/frontend/src/lib/chat{Events,Format,Text,Unread}.ts` (with their tests)
- `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`, `../sm-bm-app/plugins/jz/bm/chat.go` — host context and access wiring to mirror in `golem15.demo`
- `.planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/` plans for the presentations port (precedent for porting a bm Vite view into Nuxt)
### Plugins
- `../sm-chat-plugin/README.md` — Context contract, Service, events, generic API, channel authorizer
- `../sm-chatchannels-plugin/README.md` — channels, access resolver
- `../sm-chatvideo-plugin/README.md` — LiveKit config keys, API, reconcile job, limits
- `../sm-chatbots-plugin/README.md` — command catalog, dispatch, bot-auth seam
- `../sm-forum-plugin/README.md`, `../sm-herald-plugin/README.md`, `../sm-userfriends-plugin/README.md`
- `.planning/phases/19-chat-and-chatchannels/19-CONTEXT.md` — chat port contract (PHP parity, D-07 generic API mirrors WavePath, which must not drift)
- `.planning/phases/19.1-chat-satellites/19.1-CONTEXT.md` — satellites decisions
- `.planning/notes/core-plugins-own-repos.md` — new shared plugin and layer repos, submodule mounting, commit order
### Existing layers (pattern)
- `../vue-journal-layer`, `../vue-faq-layer`, `../vue-menu-layer`, `../vue-paymentgateway-layer`, `../vue-oauth-layer` — layer structure, tags, how `vue-demo-app` consumes them
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- bm chat SPA (about 4.6k lines with tests): the store, the API client and the event handling port almost directly; components need Nuxt/SSR guards (client-only realtime).
- The demo's Centrifugo and `lighthouse` wiring from 19.4.1 plan 04 (speaker/remote sync) carries chat realtime too; chat authorizes `chat:conversation:{id}` and chatvideo `video:session:{id}`.
- `golem15.demo` plugin and `demo:seed`, with the EN/PL seeding through translate (19.4.1 D-13).
- The `realtime_test.go`, `seed_test.go`, `reset_test.go` and `go_mod_test.go` patterns in sm-demo-app for the new plugins.
### Established Patterns
- Shared plugin and layer repos are their own git.golem15.com repos, mounted as submodules. Plugins are pushed and tagged first, then the pointer bump in the app goes in a separate commit.
- `go.mod` requires tagged versions (19.4.1 D-03); `scripts/check-pins.sh` and `TestGoModRequiresTags` enforce checkouts at the tag.
- chat/chatchannels APIs and events are PHP-parity contracts for WavePath (Phase 19 D-07, Phase 31). Changes are additive only.
### Integration Points
- `golem15.demo`: chat `contracts.Context`, chatchannels `ChannelAccessResolver`, seed, moderation word lists, chatbots command registrations.
- `vue-demo-app/nuxt.config.ts` `extends`: four new layers.
- rome: new supervisor program for LiveKit, firewall and nginx changes, `.env` keys (LiveKit, VAPID).
</code_context>
<specifics>
## Specific Ideas
- "There is a simple chat view in sm-bm-app": port it rather than build chat UI from scratch.
- The video UI starts small but as a reusable layer ("make it as initial vue-chatvideo-layer"), so later sites and the WavePath work can grow it.
- The user prefers reusable shared pieces over demo-only code: a layer for each satellite with UI, and a shared moderation plugin rather than a filter inside `golem15.demo`.
</specifics>
<deferred>
## Deferred Ideas
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
- User-created channels (public or private) and ad-hoc group DMs with group calls.
- A live bot persona answering in channels.
- A read-only `/chat` preview for anonymous visitors.
- Admin screens for chat-family plugins (deleting messages from the backend); D-22 uses a site-side moderator group for now.
### Reviewed Todos (not folded)
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
</deferred>
---
*Phase: 20-demo-instance-with-every-shared-plugin*
*Context gathered: 2026-10-28*

View File

@@ -0,0 +1,137 @@
# Phase 20: Demo instance with every shared plugin - 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:** 20-demo-instance-with-every-shared-plugin
**Areas discussed:** Chat frontend source, Chat context & access, Video calls hosting, Abuse & satellites scope
The user corrected the opening premise: "There is simple chat view in sm-bm-app". bm's Vite SPA chat (about 4.6k lines) became the port source.
Folded todo: "Commit sm-demo-app's leftover journal and oauthserver submodule moves" (Fold it in).
---
## Chat frontend source
| Option | Description | Selected |
|--------|-------------|----------|
| New vue-chat-layer | Shared Nuxt layer; bm-specific bits stripped or slots | ✓ |
| Demo-only pages | Port into vue-demo-app as 19.4.1 did for presentations | |
| Layer, but later | Demo-only now, extraction as backlog todo | |
| Option | Description | Selected |
|--------|-------------|----------|
| Full bm feature set | Channels, DMs, threads, mentions, reactions, typing, unread, attachments | ✓ |
| Core only | Channels and DMs with realtime, unread, typing | |
| Option | Description | Selected |
|--------|-------------|----------|
| Dedicated /chat page | Full-page chat plus tour card | |
| Page + floating panel | /chat plus floating DM/unread widget | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Read-only #general preview | Live read-only seeded channel | |
| Login prompt only | As quizzes D-18 | ✓ |
---
## Chat context & access
| Option | Description | Selected |
|--------|-------------|----------|
| One demo community | Single context like bm/1 | ✓ |
| Per-user workspaces | Users create contexts | |
| Option | Description | Selected |
|--------|-------------|----------|
| Seeded public + user private | Users create private channels | |
| Anyone, any channel | | |
| Seeded only | Join seeded channels, DM each other | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Seeded users + history, EN/PL | Persona users with history | ✓ |
| Plus a live bot | Bot answers slash commands | |
| Empty channels | | |
| Option | Description | Selected |
|--------|-------------|----------|
| Any member of the community | userfriends does not gate DMs | ✓ |
| Friends only | | |
---
## Video calls hosting
| Option | Description | Selected |
|--------|-------------|----------|
| Self-host on rome | LiveKit under supervisor, own keys | ✓ |
| LiveKit Cloud free tier | | |
| Decide in research | | |
| Option | Description | Selected |
|--------|-------------|----------|
| DMs and private groups | No calls in public channels | ✓ |
| Any conversation | | |
| Option | Description | Selected |
|--------|-------------|----------|
| LiveKit components in the chat layer | Call view on livekit-client | |
| Minimal call page | Bare-bones join page | ✓ (as the initial vue-chatvideo-layer) |
**Notes:** "make it as initial vue-chatvideo-layer". Since channels are seeded only and chat has no ad-hoc group DMs, calls are effectively 1:1 DMs.
| Option | Description | Selected |
|--------|-------------|----------|
| Short calls, few people | ~15 min, ~4 participants | ✓ |
| Plugin defaults | | |
---
## Abuse & satellites scope
| Option | Description | Selected |
|--------|-------------|----------|
| Rate limits + report + reset | No content filter | |
| Plus a word filter | Banned words/link filter | ✓ |
| Posting needs approval | | |
Follow-up, filter home: Hook in plugins + list in demo / Demo plugin only / **New shared plugin** ✓
| Option | Description | Selected |
|--------|-------------|----------|
| Forum page + friends in chat + /commands | Built in demo or chat layer | |
| A layer for each | vue-forum-layer, vue-userfriends-layer | ✓ |
| Forum only | | |
Follow-up, which layers: **forum + userfriends; commands in chat layer** ✓ / Also a vue-chatbots-layer
| Option | Description | Selected |
|--------|-------------|----------|
| Push for DMs and mentions | Opt-in notifications | |
| Mounted, no UI | | ✓ |
| Option | Description | Selected |
|--------|-------------|----------|
| Backend first, then layers | 5-plan split | ✓ |
| You decide at plan time | | |
---
## Claude's Discretion
- Seeded channel names, personas, history volume
- LiveKit port/TURN exposure on rome
- Shape of the pre-save validator seam in chat and forum
- Demo chatbots commands
- vue-chat-layer extension points for embeds
## Deferred Ideas
- Web Push subscribe UI
- User-created channels and ad-hoc group DMs/group calls
- Live bot persona
- Anonymous read-only chat preview

View File

@@ -0,0 +1,288 @@
# Phase 20: Demo instance with every shared plugin - Pattern Map
**Mapped:** 2026-10-28
**Files analyzed:** 34 (grouped by repo)
**Analogs found:** 31 / 34
Paths are relative to the summercms.go repo root. The analogs are tracked source in their own repos. bm's `plugins/golem15/*` trees are nested submodules of sm-bm-app, and `plugins/jz/bm` is its own repo. Edit the shared plugins only in their `../sm-*-plugin` checkouts, after fast-forwarding them (RESEARCH Pitfall 1). Read the bm copies for reference only.
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|---|---|---|---|---|
| `../sm-chat-plugin/events/events.go` (+`MessageSaving`) | event type | event-driven (pre-save) | `sm-user-plugin/classes/events.go` (DeactivatingEvent, lines 60-90) | role-match |
| `../sm-chat-plugin/contracts/rejection.go` (new) | contract/error | request-response | none (new type); errors pattern in `chat/api/errors.go:35-45` | partial |
| `../sm-chat-plugin/classes/service.go` (fire seam in `SendMessageWith`, `EditMessage`) | service | CRUD | itself, lines 287-345 | exact |
| `../sm-chat-plugin/api/errors.go` (map Rejection to 422) | api helper | request-response | itself, `writeValidation` lines 39-44 | exact |
| `../sm-forum-plugin/events/events.go` (new pkg, `PostSaving`) | event type | event-driven | chat `events/events.go` | role-match |
| `../sm-forum-plugin/classes/forum_service.go` (fire in CreateThread/Reply/EditPost) | service | CRUD | itself, lines 263-300 (`Bus` field line 57-58, unused) | exact |
| `../sm-forum-plugin/api/errors.go` | api helper | request-response | itself, `writeValidation` line 28, `writeForumError` line 44 | exact |
| `../sm-chatvideo-plugin/go.mod`, `../sm-herald-plugin/go.mod`, `../sm-userfriends-plugin/go.mod` | config | n/a | `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1 "drop sibling replaces") | exact |
| `../sm-moderation-plugin/plugin.go` | plugin entry | boot | `../sm-sitemap-plugin/plugin.go` lines 1-80 | role-match |
| `../sm-moderation-plugin/admin.go` (HasSettings) | admin | CRUD (settings singleton) | `../sm-sitemap-plugin/admin.go` lines 40-72 | exact |
| `../sm-moderation-plugin/models/settings.go` + `models/settings/fields.yaml` | model | CRUD | `../sm-sitemap-plugin/models/settings.go`, `models/settings/fields.yaml` | exact |
| `../sm-moderation-plugin/classes/filter.go` | service | transform | none | no analog |
| `../sm-moderation-plugin/updates/`, `lang/`, `config/`, `README.md` | migration/lang/config | n/a | `../sm-sitemap-plugin/{updates,lang,config,README.md}` | exact |
| `../sm-demo-app/plugins/demo/chat.go` | host wiring | boot/event-driven | `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95 | exact |
| `../sm-demo-app/plugins/demo/classes/chat_context.go` (DemoContext, ChannelContext adapter, access resolvers) | model/contract impl | request-response | `../sm-bm-app/plugins/jz/bm/classes/chat_context.go` | exact |
| `../sm-demo-app/plugins/demo/realtime_access.go` | middleware | request-response | `../sm-bm-app/plugins/jz/bm/realtime_access.go` | exact |
| `../sm-demo-app/plugins/demo/routes.go` (mountRealtime guard, chat bootstrap routes) | route | request-response | itself, lines 60-87 | exact |
| `../sm-demo-app/plugins/demo/chat_api.go` (`GET /_demo/api/v1/chat`, `/chat/members`) | controller | request-response | `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go`; style `plugins/demo/quizzes_api.go` | exact |
| `../sm-demo-app/plugins/demo/chatbots.go` (demo commands) | service | request-response | RESEARCH Code Examples "Chatbots command"; contracts `chatbots/contracts/slash_command.go` | partial |
| `../sm-demo-app/plugins/demo/plugin.go` (Requires, Middlewares, Boot calls registerChat) | plugin entry | boot | itself, lines 33-100 | exact |
| `../sm-demo-app/plugins/demo/console/seed_chat.go`, `seed_forum.go`, `seed_friends.go` | console/seed | batch | `plugins/demo/console/seed.go` (`seedQuiz` 309-340, `insertID`/`exists` 177-189) | exact |
| `../sm-demo-app/plugins/demo/console/reset.go` (`resetTables`) | console | batch | itself, lines 21-75 | exact |
| `../sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules}` | config | n/a | existing entries (summer.yaml plugins list) | exact |
| `../sm-demo-app/go_mod_test.go`, `boot_test.go`, `realtime_test.go`, `reset_test.go`, `seed_test.go` (extend) + new `chat_test.go`, `seed_chat_test.go` | test | n/a | the same files (`TestGoModRequiresTags` :22, `TestBootAllPlugins` :31, `TestRealtimeRoutes` :124) | exact |
| `../sm-demo-app/deploy/centrifugo.json.example` (chat/user/video namespaces) | config | pub-sub | itself lines 41-46 + `../sm-bm-app/deploy/centrifugo/bm-config.json` lines 39+ | exact |
| `../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf`, `deploy/livekit.yaml.example` | config | n/a | `deploy/supervisor/summercms-demo-centrifugo.conf`; RESEARCH supervisor block | exact |
| `../sm-demo-app/deploy/nginx/demo.summercms.io.conf`, `scripts/{dev.sh,check-deploy.sh,check-live.sh,check-local.sh}`, `DEPLOY.md` | config/script | n/a | the same files (Centrifugo `/ws` sections) | exact |
| `../vue-chat-layer/nuxt.config.ts`, `package.json` | config | n/a | `../vue-faq-layer/nuxt.config.ts`, `package.json` | exact |
| `../vue-chat-layer/app/composables/useChatApi.ts` (auth hook `chatAuth`) | composable | request-response | `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` | exact |
| `../vue-chat-layer/app/composables/useChat.ts` (store) | store | event-driven | `../sm-bm-app/frontend/src/stores/chat.ts` (Pinia setup store, convert) | role-match |
| `../vue-chat-layer/app/lib/realtime.ts` (+`onUserEvent`) | utility | pub-sub | `../sm-demo-app/vue-demo-app/app/lib/realtime.ts` (`openChannel` :58, dynamic import :59) | exact |
| `../vue-chat-layer/app/lib/chat{Events,Format,Text,Unread}.ts` + tests, `app/components/chat/*` | utility/component | transform | `../sm-bm-app/frontend/src/lib/chat*.ts`, `frontend/src/components/chat/*` | exact (port) |
| `../vue-chat-layer/app/components/chat/{ChannelList,ThreadPane,PinsPanel,CommandPicker}.vue` | component | request-response | none in bm (bm has one channel, no threads/pins) | no analog |
| `../vue-chatvideo-layer/*` (call page on livekit-client) | component/composable | streaming | none (bm has no video UI) | no analog |
| `../vue-forum-layer/*`, `../vue-userfriends-layer/*` | layer | request-response | `../vue-faq-layer` (structure) + `../vue-paymentgateway-layer` (authenticated client) | role-match |
| `../sm-demo-app/vue-demo-app/{nuxt.config.ts,package.json,app/plugins/auth.ts}` | config/provider | n/a | itself: `extends` line 51, layer deps lines 19-22, auth.ts | exact |
| `../sm-demo-app/vue-demo-app/app/pages/{chat.vue,forum.vue,friends.vue,call/[id].vue}` | page | request-response | `app/pages/quizzes/index.vue` (anonymous register prompt, lines 46, 68) | exact |
| `../sm-demo-app/vue-demo-app/app/pages/index.vue` (tour cards) | page | n/a | itself, `<TourCard` line 40 | exact |
Edit `sm-demo-app/vue-demo-app` (the submodule), not the stale `../vue-demo-app` (RESEARCH Anti-Patterns).
## Pattern Assignments
### Chat / forum pre-save seam (D-18)
**Event type analog:** `sm-user-plugin/classes/events.go` (read here at `../sm-demo-app/plugins/golem15/user/classes/events.go`), lines 13-17 and 74-86:
```go
const DeactivatingEventName = "user.Deactivating"
// ... A listener error rolls the deactivation back.
type DeactivatingEvent struct {
User *models.User
Tx *gorm.DB
}
```
`MessageSaving` and `PostSaving` follow the same doc-comment style and add a `<Name>EventName` const. They fire **before** the transaction, so they carry no `Tx`.
**Fire site, chat:** `../sm-chat-plugin/classes/service.go` `SendMessageWith` (lines 287-345 at v0.1.3). Insert the call after the `ErrEmptyMessage` check (line ~311) and before `lagoon.Transaction`. Copy the existing nil-bus guard:
```go
if s.bus != nil {
if err := s.bus.Fire(ctx, &events.MessageCreated{MessageEvent: events.MessageEvent{
MessageID: msg.ID, ConversationID: conversationID, ActorID: senderID, RootID: rootID, Tx: tx,
}}); err != nil {
return err
}
}
```
Do the same in `EditMessage` after `CanEditMessage`. `Fire` returns `errors.Join`, so callers use `errors.As`.
**Fire site, forum:** `../sm-forum-plugin/classes/forum_service.go` `CreateThread` lines 263-281. Fire between the `CanPost` check and `html := breakwater.Render(body)` (line 281). Do the same in `Reply` (line 316, before the Render at 330) and in `EditPost`. Use `s.Bus` (field at line 57-58), guarded for nil.
**422 mapping:** `../sm-chat-plugin/api/errors.go` lines 39-44 (forum: `api/errors.go:28`):
```go
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": errValidation,
"errors": errs,
})
}
```
In the service-error mapper (`writeChatError` / `writeForumError` line 44), add `var rej contracts.Rejection; if errors.As(err, &rej) { writeValidation(w, map[string][]string{rej.Field: {rej.Message}}); return }` before the 500 default. With no listener registered, no existing branch changes.
**Tests:** keep `php_method_map_test.go` and `golden_test.go` green. Add the seam tests next to `tombstone_test.go` / `upload_limit_test.go`, using the `harness_test.go` fixture.
---
### `../sm-moderation-plugin` (new shared plugin)
**Plugin entry analog:** `../sm-sitemap-plugin/plugin.go` lines 1-60. Copy the package doc comment, the interface assertions and the embedded config:
```go
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
type Plugin struct { app *backpack.App }
func (p *Plugin) ID() string { return "golem15.sitemap" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
```
Publish `*classes.Filter` from an `lagoon.OnDatabase` hook in Boot, the way chat publishes its Service (`app.Lookup[*chatclasses.Service]()` in bm chat.go:58). The plugin must not import chat or forum.
**Settings analog:** `../sm-sitemap-plugin/admin.go` lines 54-72:
```go
{
Code: "sitemap_settings",
Label: "golem15.sitemap::lang.settings.label",
Description: "golem15.sitemap::lang.settings.description",
Category: "golem15.sitemap::lang.settings.category",
Icon: "settings",
Permissions: []string{controllers.PermissionAccessDefinitions},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &models.Settings{} },
},
// ...
_ pact.HasSettings = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
```
**Model:** `../sm-sitemap-plugin/models/settings.go` uses a singleton row id 1 on `golem15_<plugin>_settings`, `*string` columns, and validation in `BeforeValidate` with a lang-key error const (`errFrontendURL`). The moderation model gets `banned_words` and `banned_links` (textarea, one per line). **fields.yaml:** copy the header block and the `label/comment/type/span` shape from `../sm-sitemap-plugin/models/settings/fields.yaml`.
**Filter (no analog):** a stdlib-only `Check(ctx, text) *Violation`: case-insensitive word-boundary match for words; links matched by scheme, host or bare domain. Use table tests.
---
### `../sm-demo-app/plugins/demo/chat.go` (host wiring)
**Analog:** `../sm-bm-app/plugins/jz/bm/chat.go` lines 1-95.
Imports (lines 3-15). Swap the bm classes for `sm-demo-app/plugins/demo/classes` and add the forum, chatbots, moderation and chat events imports:
```go
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts"
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
```
Core (lines 35-50, 72-87). Add a second `reg.Add` for `chanmodels.ChannelAlias` (RESEARCH Pattern 1):
```go
if app == nil || !app.HasPlugin("golem15.chat") { return nil }
reg, ok := app.Lookup[*chatcontracts.Registry]()
if !ok || reg == nil { return errors.New("jz.bm: golem15.chat must be listed before jz.bm in summer.yaml") }
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}); err != nil { return err }
...
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
svc, ok := app.Lookup[*chanclasses.ChannelService]()
if !ok || svc == nil { return errors.New("... must be listed before ...") }
svc.SetAccessResolver(classes.NewChatAccess(p.database, p.chatGroup(), nil))
return nil
})
```
Copy the config-key const block (lines 17-28) for `golem15.demo.chat.moderator_group` and the limits. The moderation listeners follow RESEARCH Pattern 2 (`app.Events.Listen[*chatevents.MessageSaving](p.ID(), ...)`).
### `../sm-demo-app/plugins/demo/classes/chat_context.go`
**Analog:** `../sm-bm-app/plugins/jz/bm/classes/chat_context.go`. Copy:
- The interface assertions (lines 12-17).
- The const block (lines 21-35): `ChatContextType = "demo"`, `ChatContextID = 1`, `ChatBroadcastChannel = "chat:context:1"`. The broadcast channel must never be empty.
- `MemberFunc` injection and `WithMembership` for tests.
- The five one-line methods (`ID/Type/ChatEnabled/GroupName/BroadcastChannel`).
- `CanActFor` → `chatcontracts.DefaultCanActFor`; `CanOverseeChats` false.
- `CanModerateMessage` limited to `conversationType == "group"` plus the moderator group (D-22).
- `RequireLiveMembership` true.
Difference from bm: `HasMember` means "registered, not deleted, not banned". There is no group check (D-06), so drop `ChatMemberIDs`'s group join and query `users` directly. Add a `ChannelContext` with the same methods, loaded by channel id, and `BroadcastChannel() = "chat:channel:{id}"`.
### `../sm-demo-app/plugins/demo/realtime_access.go`
**Analog:** `../sm-bm-app/plugins/jz/bm/realtime_access.go` lines 1-70. Copy `MiddlewareRealtimeAccess` (name it `demo.realtime-access`), the `presentationsOwnerGuard` name const, the `sync.Once` owner-guard lookup, and the 401/503/member-pass flow (`bouncer.User(r.Context())`, `writeRealtimeGuard`). Then change `../sm-demo-app/plugins/demo/routes.go:78-86`. Mount when presentations **or** chat is active, and replace `"presentations.realtime-owner"` with the new guard:
```go
return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{
UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime),
ServerToServer: surf.Use(),
Middleware: surf.Use(),
})
```
`plugin.go` gains `pact.HasMiddleware` (`Middlewares() map[string]pact.Middleware`, bm realtime_access.go:24-29).
### `../sm-demo-app/plugins/demo/chat_api.go`
**Analog:** `../sm-bm-app/plugins/jz/bm/controllers/api/chat.go` (bootstrap JSON shape: channels with unread and mentions, DMs, members, limits). Mount it the way `routes.go:60-62` does: `r.Group(APIPrefix, surf.Use("jwt.auth", "throttle:120,1"), func(g pact.Router) { g.Get("/chat", ...) })`. Write errors with `wire.WriteJSON(w, status, map[string]string{"error": ...})` (routes.go:98).
### Seed and reset
**Seed analog:** `../sm-demo-app/plugins/demo/console/seed.go`:
- Helpers at lines 177-189 (`exists`, `insertID` with `RETURNING id`).
- `seedQuiz` (309-340) is the idempotent `(bool, error)` step shape, registered through `created(...)` in `runSeed` (40-80).
- Use raw SQL only. Do not go through `chat.Service` or a `CreateChannel` gated by the resolver (RESEARCH Anti-Patterns).
- Locale variants follow `seed_locales.go`.
**Reset analog:** `../sm-demo-app/plugins/demo/console/reset.go:21-40`, `resetTables []struct{ table, where string }`, children first. Insert the chat-family tables before `users` in the order from RESEARCH Pitfall 10. Keep `golem15_moderation_settings`.
### Tests (sm-demo-app)
Extend `go_mod_test.go:22 TestGoModRequiresTags` (its `wantPlugins` list), `boot_test.go:31 TestBootAllPlugins` (route prefixes), `realtime_test.go:124 TestRealtimeRoutes` (any member gets 200) and `reset_test.go`. New `chat_test.go` follows `demo_api_test.go`, and `seed_chat_test.go` follows `seed_test.go`, Docker-gated through `testmain_test.go`.
### Deploy config
- **Centrifugo:** `../sm-demo-app/deploy/centrifugo.json.example:41-46` uses the `{"name": ..., "subscribe_proxy_enabled": true}` format. Add `chat` (presence and join_leave options from `../sm-bm-app/deploy/centrifugo/bm-config.json` namespaces, line 39+), `user` and `video`. Keep the demo's option names, not bm's named proxies (RESEARCH A6).
- **Supervisor:** copy `deploy/supervisor/summercms-demo-centrifugo.conf` to `summercms-demo-livekit.conf` using the RESEARCH "Supervisor program" block. **LiveKit yaml:** use the RESEARCH block (ports 7890/7891/7892).
- **nginx:** add the `location ^~ /livekit/` block next to the existing `/ws` block in `deploy/nginx/demo.summercms.io.conf`, and extend the Go path allow-list with `/_chat/ /_chatchannels/ /_chatvideo/ /_chatbots/ /_forum/ /_userfriends/`.
---
### `../vue-chat-layer` (and the structure of the other three layers)
**Config analog:** `../vue-faq-layer/nuxt.config.ts`. Copy:
- The header comment ("An app consumes it with `extends: [...]`").
- `const currentDir = fileURLToPath(new URL('.', import.meta.url))` with absolute css paths.
- `runtimeConfig.<x>.apiBase` (server, `NUXT_<X>_API_BASE`) and `runtimeConfig.public.<x>.apiBase` (same-origin).
Leave out the faq-specific `cachePurge`/`siteCache` keys. Add `public.chat.hostApiBase` (default `/_demo/api/v1` must NOT be hard-coded with an app name; default `''` and let the app set it) and `routeRules: { '/chat/**': { ssr: false } }`.
**package.json analog:** `../vue-faq-layer/package.json`: `"private": true, "type": "module", "main": "./nuxt.config.ts"`, `engines.node >=22.18`, `peerDependencies.nuxt 4.4.8`, devDeps nuxt 4.4.8 / typescript 5.9.3 / vue 3.5.35. For vue-chat-layer, replace the `node --test` script with vitest 3.2.7 and add `centrifuge@5.7.3`.
**Auth-hook client analog:** `../vue-paymentgateway-layer/app/composables/usePaymentGatewayApi.ts` lines 1-55. Copy the doc comment with the `defineNuxtPlugin` provide example, the hook lookup through `useNuxtApp()`, `Authorization: Bearer`, and the server-vs-browser base split:
```ts
type AuthHook = () => string | null | undefined
const auth = (useNuxtApp() as unknown as { $paymentGatewayAuth?: AuthHook }).$paymentGatewayAuth
const authHeaders = (): Record<string, string> => {
const token = typeof auth === 'function' ? auth() : null
return token ? { authorization: `Bearer ${token}` } : {}
}
```
For chat, the hook becomes `$chatAuth: { token(), refresh(), unauthorized() }` (RESEARCH Pattern 4). The pure client goes in `app/utils/client.ts` (as in faq/paymentgateway `utils/client.ts`) so `tests/*.test.ts` can test it without Nuxt.
**App wiring:** `../sm-demo-app/vue-demo-app/app/plugins/auth.ts`:
```ts
export default defineNuxtPlugin(() => ({
provide: { paymentGatewayAuth: () => useCookie<string | null>(DEMO_TOKEN_COOKIE).value ?? null },
}))
```
Add `chatAuth` (and forum/friends/video hooks) to the same `provide`. Append the layers to `nuxt.config.ts:51` `extends`, and add `package.json` deps in the `git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z` form (lines 19-22).
**Store port:** `../sm-bm-app/frontend/src/stores/chat.ts`. It imports `defineStore` from 'pinia' plus `@/api/chat`, `@/lib/chatEvents`, `@/stores/toast`. Convert it to a module-level composable that uses `useState` (no Pinia, 19.4.1). Replace `general`/`generalId` with `channels[]`. Strip the bm `embeds` lib (`deadline|class|reading`) and add a slot or renderer registry for it.
**Realtime analog:** `../sm-demo-app/vue-demo-app/app/lib/realtime.ts`: `REALTIME_WS_PATH = '/ws'` (:7), `UNAVAILABLE = new Set([401,403,404])` (:19), `tokenFailureIsFinal` (:22), `wsURL` (:42), `openChannel` with `await import('centrifuge')` (:58-59). Move it into the layer as one shared client and add `onUserEvent(handler)` (Pattern 3). vue-chatvideo-layer reuses it and must not open a second client.
**Pages, anonymous prompt analog:** `../sm-demo-app/vue-demo-app/app/pages/quizzes/index.vue`. The doc comment (line 4) covers the anonymous prompt; `registerLink` (line 46) is `{ path: '/register', query: { next } }`; line 68 renders `<NuxtLink class="button button--small" :to="registerLink(...)">{{ t('quiz.registerToTake') }}</NuxtLink>`. Apply it to `/chat` (D-04) and `/forum` (D-21). Tour cards follow `<TourCard` in `app/pages/index.vue:40`. Wrap the floating panel in `<ClientOnly>` in the app layout.
## Shared Patterns
### Host wiring through the service registry plus OnDatabase
**Source:** `../sm-bm-app/plugins/jz/bm/chat.go:55-87`
**Apply to:** demo chat.go (chat limits, chatchannels resolver, forum resolver, chatbots registry, moderation filter lookup). Use the guard `if !app.HasPlugin(id) { return nil }`, then `app.Lookup[*T]()` inside `lagoon.OnDatabase`. A missing service returns the error "must be listed before … in summer.yaml".
### Festival events with listener-error rollback
**Source:** `sm-user-plugin/classes/events.go:60-110`, fire sites `classes/access_revoked.go:50`, `backend_links.go:358` (`app.Events.Fire(ctx, &X{...})`)
**Apply to:** the chat and forum seams and the demo listeners.
### Error envelope
**Source:** `../sm-chat-plugin/api/errors.go:35-45`, `../sm-forum-plugin/api/errors.go:24-44`: `{"error": msg}` and the 422 `{"error":"Validation failed","errors":{field:[msg]}}` through `wire.WriteJSON`.
**Apply to:** the seam rejection and the demo chat bootstrap API.
### Layer conventions
**Source:** `../vue-faq-layer` (config, package, `tests/*.test.ts`, `app/utils/client.ts` + `app/composables/use*Api.ts`), `../vue-paymentgateway-layer` (auth hook).
**Apply to:** all four new layers. They name no app and have no Pinia.
### Release hygiene
**Source:** `sm-bm-app/plugins/golem15/chatbots/go.mod` (commit d0b9fb1, no sibling replaces). Requires are tags only. `../sm-demo-app/scripts/check-pins.sh` and `go_mod_test.go:22`.
**Apply to:** every plugin go.mod and the demo pointer bumps. Make one `chore: bump <plugin> to <tag>` commit per plugin.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `../sm-moderation-plugin/classes/filter.go` | service | transform | No text filter exists. Use RESEARCH D-17 test map (word boundary, links). |
| `../vue-chat-layer` ChannelList / ThreadPane / PinsPanel / CommandPicker | component | request-response | bm UI has one channel, no threads or pins. Build on chatchannels pins and chat replies APIs (RESEARCH OQ 4). |
| `../vue-chatvideo-layer` call page | component | streaming | No video UI anywhere. Use livekit-client (human-verify checkpoint first), loaded with dynamic `import()` like centrifuge. |
## Metadata
**Analog search scope:** sm-demo-app (plugins/demo, deploy, scripts, vue-demo-app), sm-bm-app (plugins/jz/bm, plugins/golem15/{chat,chatchannels,chatbots}, frontend/src), sm-forum-plugin, sm-sitemap-plugin, sm-user-plugin (via demo submodule), vue-faq-layer, vue-paymentgateway-layer
**Files scanned:** ~40
**Pattern extraction date:** 2026-10-28

View File

@@ -0,0 +1,694 @@
# Phase 20: Demo instance with every shared plugin - Research
**Researched:** 2026-10-28
**Domain:** Mounting the chat family (chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends) into `sm-demo-app`, new shared Nuxt layers, a self-hosted LiveKit on rome, a new shared moderation plugin
**Confidence:** HIGH for the backend and repo state (read and compiled this session), MEDIUM for LiveKit deployment, MEDIUM for the layer port effort
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Chat frontend
- **D-01:** The chat UI is ported from `sm-bm-app`'s Vite SPA (`frontend/src/components/chat/`, `stores/chat.ts`, `api/chat.ts`, `lib/chat*.ts`, about 4.6k lines) into a new shared Nuxt layer, **`vue-chat-layer`**, consumed by `vue-demo-app` like the journal, menu, FAQ and paymentgateway layers. The layer is Golem15 code with no bm (JZ) specifics: BM link embeds, Terminarz deadline pins and other bm-only pieces are stripped or turned into slots or extension points. The layer names no application. — **Reversibility:** costly. A public layer with tagged releases; later sites pin it by tag.
- **D-02:** The full bm feature set: channels, DMs, threads, mentions, reactions, typing, unread counts, attachments, member picker, delete dialog, embeds (generic link embeds only).
- **D-03:** Chat appears as a full `/chat` page (channel sidebar, message pane) **and** a floating panel (DM list and unread badge) on every page for logged-in users. The homepage tour (19.4.1 D-11) gets a "Chat" card.
- **D-04:** Anonymous visitors on `/chat` see a description and a "register to chat" prompt, the same pattern as quizzes (19.4.1 D-18). There is no read-only preview, and the floating panel is hidden for them.
- **D-05:** Chatbots has no UI of its own. Its slash commands appear as a command picker in the `vue-chat-layer` composer. There is no `vue-chatbots-layer`.
#### Chat context and access
- **D-06:** One demo community: a single chat context (in the spirit of bm's `bm/1`, `plugins/jz/bm/classes/chat_context.go`, e.g. `demo/1`) implemented in `golem15.demo` as the host `contracts.Context` and the chatchannels `ChannelAccessResolver`. Every registered user is a member automatically.
- **D-07:** Channels are **seeded only** (for example #general, #announcements, a Polish channel). Users join seeded channels and cannot create channels of any kind.
- **D-08:** Any community member can DM any other member, as in bm. userfriends does not gate DMs.
- **D-09:** `demo:seed` creates a handful of persona users with message history, threads, reactions and pins in EN and PL channels. The reset job (19.4.1 D-09) wipes and reseeds them along with everything else. There is no live bot persona.
#### Video calls
- **D-10:** LiveKit is self-hosted on rome under supervisor, next to the demo's Centrifugo (19.4.1 D-17), with its own keys in `.env`. The media ports (UDP range, TCP fallback, TURN/TLS through nginx stream or LiveKit's embedded TURN) are opened on rome and documented in `sm-demo-app/DEPLOY.md`. — **Reversibility:** reversible. Moving to LiveKit Cloud later only changes `golem15.chatvideo.livekit_*` config.
- **D-11:** Calls start from DMs only. chat has DMs plus one context-wide group conversation (`GetGroupChat`) and no ad-hoc group DMs, and with D-07 there are no private channels, so in practice that means 1:1 calls. The researcher confirms this against the plugin. Calls are never started in seeded public channels.
- **D-12:** Caps for a public demo: `max_duration` about 15 minutes, about 4 participants per room, the plugin's existing rate limits, and the `chatvideo:reconcile-sessions` job ending idle rooms.
- **D-13:** The video UI is a **minimal call page** (own camera, remote tiles, mute, camera toggle, leave) built on `livekit-client` and released as the first version of a new shared **`vue-chatvideo-layer`**. bm has no video UI to port.
#### Forum, friends, push
- **D-14:** **`vue-forum-layer`** is a new shared layer with a `/forum` page: categories, threads and Markdown posts, seeded in EN and PL by `demo:seed`.
- **D-15:** **`vue-userfriends-layer`** is a new shared layer: friends list, requests and friend groups. It is linked from the chat sidebar and the profile.
- **D-16:** herald (Web Push) is mounted and configured (VAPID keys in `.env`) but has **no subscribe UI** in this phase.
#### Moderation
- **D-17:** A new shared plugin, **`sm-moderation-plugin`**, filters banned words and links in chat messages and forum posts. The banned-word and link lists are editable in its admin settings. On top of it: the plugins' rate limits, Turnstile on register (19.4.1 D-07), admins deleting messages and banning users from the backend, and the reset job.
- **D-18:** chat and forum fire their festival events after the save (`events.MessageCreated` etc.), so the filter needs a **new, generic, additive pre-save seam** in `sm-chat-plugin` and `sm-forum-plugin`: a host- or plugin-registered validator that can reject a message or post before it is persisted. The change is additive and ships as patch tags. It must not change the existing PHP-parity API or payloads (these plugins are WavePath's port target, Phase 31), and the seam names no application. — **Reversibility:** costly. A public plugin contract other consumers can start using.
#### Plan shape (suggestion; confirmed at plan time per the lean-planning rule)
- **D-19:** Backend first, then layers. The suggested split from the discussion, before the forum and userfriends layers and the moderation plugin were added: 01 mount and tag the chat family, demo context, seed, LiveKit and the rome deploy (with the folded todo); 02 the `vue-chat-layer` port; 03 `vue-chatvideo-layer` plus herald config; 04 forum and friends layers, slash-command picker, tour cards; 05 unit tests and live verification. The planner fits in `sm-moderation-plugin`, the pre-save seams and the two extra layers, and presents the final count for confirmation.
#### Folded Todos
- **Commit sm-demo-app's leftover journal and oauthserver submodule moves** (`.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`, g15office `ssuyq2os`): sm-demo-app still shows `M plugins/golem15/oauthserver`. This phase bumps submodule pointers and go.mod requires anyway, so the first plan moves each checkout to the tag go.mod requires and makes one `chore: bump <plugin> to <tag>` commit per plugin before mounting the chat family. `TestGoModRequiresTags` and `scripts/check-pins.sh` must pass.
### Claude's Discretion
- Seeded channel names and persona users, and how much EN/PL history to seed.
- How LiveKit's media and TURN ports are exposed on rome (embedded TURN vs nginx stream), as long as calls work from typical NAT'd browsers.
- The exact shape of the pre-save validator seam in chat and forum (interface name, registration through `pact` or the service registry), within D-18's constraints.
- Which chatbots commands are registered for the demo (`/help`, `/demo`, …) and what they answer.
- How `vue-chat-layer` exposes extension points for app-specific embeds.
### Deferred Ideas (OUT OF SCOPE)
- Web Push subscribe UI (opt-in notifications for DMs and mentions, sharing the PWA service worker): herald is mounted without UI here.
- User-created channels (public or private) and ad-hoc group DMs with group calls.
- A live bot persona answering in channels.
- A read-only `/chat` preview for anonymous visitors.
#### Reviewed Todos (not folded)
- "Prompt open admin and site tabs to reload when a new version is deployed": its site half was folded into 19.4.1 (D-22); the admin half is framework work, not this phase.
- "Translate Messages as a Nuxt i18n JSON editor", "Port the menu plugin as sm-menu-plugin…", and the user/mfa/cabana/build todos that matched on keywords only: they are unrelated to the chat family on the demo.
</user_constraints>
<phase_requirements>
## Phase Requirements
No requirement IDs are mapped (ROADMAP: "Requirements: TBD"). The success criteria and D-01..D-19 are the source of truth.
| ID | Description | Research Support |
|----|-------------|------------------|
| SC-1 | `sm-demo-app` builds with every shared plugin incl. chat, chatchannels, chatvideo, chatbots, forum, herald, userfriends; `demo:seed` fills each | §Repo state and tagging, §Demo host wiring, §Seeding |
| SC-2 | Public Nuxt pages show chat/channels with realtime, video calls, presentations, journal, others through shared layers | §Layers, §Realtime, §LiveKit |
| SC-3 | Deployed publicly; frontend open; `/backend` unreachable; "contact Golem15" CTA | §rome deployment, Open Question 1 |
| SC-4 | New code unit-tested in the last plan | §Validation Architecture |
</phase_requirements>
## Summary
The backend half is in better shape than the repo layout suggests, but every chat-family plugin needs release work before `sm-demo-app` can require it. Only `sm-chat-plugin` has tags (v0.1.0-v0.1.3 on origin). `chatchannels`, `chatbots`, `chatvideo`, `forum`, `herald` and `userfriends` have none. Three sibling checkouts (`../sm-chat-plugin`, `../sm-chatchannels-plugin`, `../sm-chatbots-plugin`) are **behind origin**; the current code lives in `sm-bm-app/plugins/golem15/*`. `chatvideo` still requires `sm-chat-plugin v0.0.0`, and `chatvideo` and `herald` keep sibling `replace` lines. I copied all seven trees to a scratch workspace, set their requires to tags (chat v0.1.3, user v0.1.13, summercms v0.5.1) and dropped the sibling replaces. With that, all seven `go build`, `go vet` and `go test -short` pass against framework **v0.5.1**, the version `sm-demo-app` pins. Without the go.mod fix, module resolution fails with `unknown revision v0.0.0`. So the gap is release hygiene, not framework drift.
The frontend half is larger than D-01's "port about 4.6k lines" implies. bm's chat SPA is built around **one** channel (`# ogólny`) plus DMs. Its store has `general`/`generalId` throughout. It has **no thread UI, no pins, no channel list**, and its embeds are bm entity types (`'deadline' | 'class' | 'reading'`), not link embeds. It also depends on a bm host bootstrap endpoint (`/_bm/api/v1/chat` and `/chat/members`). D-02 and D-07 (several seeded channels, threads, pins) therefore need new UI: a multi-channel store, a thread pane and a pins view. The demo also needs its own bootstrap endpoints in `golem15.demo`.
The backend gaps are small but each one blocks something:
- `chatchannels` does not register the `channel` chat alias. Messages in channels made with `CreateChannel` resolve through a host "channel-as-context adapter", which `golem15.demo` must register.
- The demo's realtime token route is limited to presentation owners. Chat needs every member to get a token.
- Centrifugo needs the `chat`, `user` and `video` namespaces.
- No chat-family plugin has admin screens. "Admins deleting messages from the backend" (D-17) has no UI today.
On rome, a WavePath LiveKit 1.13.1 already runs on 127.0.0.1:7880, 7881/tcp and 50000-60000/udp. Its media ports are defined in Vuurmuur but **not applied in the firewall rules**. The demo needs its own instance on free ports and a Vuurmuur rule.
**Primary recommendation:** Plan in three stages:
1. Release work in the shared plugin repos: fast-forward the siblings, fix the go.mod files, add the pre-save seams, create `sm-moderation-plugin`, tag v0.1.x.
2. Demo host wiring and seed: the context, the channel adapter, bootstrap endpoints, the realtime guard, Centrifugo namespaces, local LiveKit.
3. Layers, with the realtime client living in `vue-chat-layer` and reused by `vue-chatvideo-layer`, then the rome deploy and the tests plan.
Set `golem15.chatvideo.max_participants: 2` so the server itself enforces D-11 (DM-only calls).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Chat persistence, permissions, realtime publish | API / Backend (`golem15.chat`) | Database | Shared plugin; host only supplies context |
| Demo community membership, channel adapter, channel/forum access policy | API / Backend (`golem15.demo`) | — | Host-specific policy (D-06); plugins stay neutral |
| Chat bootstrap (channels with unread/mentions, DMs, members, limits) | API / Backend (`golem15.demo` `/_demo/api/v1/chat*`) | — | bm keeps this host-side (`/_bm/api/v1/chat`); generic APIs lack a "my conversations across contexts" call |
| Pre-save content filter | API / Backend (`sm-moderation-plugin` service, wired by `golem15.demo` into chat/forum seams) | — | Must run before persistence; must not couple moderation to chat/forum modules |
| Slash commands | API / Backend (`golem15.chatbots` registry, demo registers commands) | Browser (composer picker) | Dispatch runs as JWT actor server-side |
| Video room lifecycle, tokens | API / Backend (`golem15.chatvideo`) | LiveKit SFU (rome) | Plugin mints tokens; media never touches Go |
| Media transport | LiveKit SFU on rome (UDP mux / ICE-TCP) | Browser (`livekit-client`) | WebRTC needs direct ports, not nginx/Cloudflare |
| Realtime fan-out | Centrifugo (demo instance) | API (subscribe proxy authorizers) | Already deployed in 19.4.1 |
| Chat/forum/friends/video UI | Browser / Client (layers, client-only) | Frontend Server (SSR shell, `/forum` may SSR) | Chat and video touch window/WebSocket; SSR only the shell |
| Anonymous prompts, tour cards | Frontend Server (vue-demo-app) | — | App-specific copy, EN/PL |
## Project Constraints (from CLAUDE.md)
- **Stdlib first.** A new Go dependency needs a research doc or decision note naming it. No new Go deps are needed beyond what the chat family already carries (`livekit/server-sdk-go/v2` and `SherClockHolmes/webpush-go` come in with `chatvideo` and `herald`). `sm-moderation-plugin` should be stdlib-only (+ `gorm`, framework modules).
- **YAML** is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
- **Framework code and shared plugins/layers never name a consuming application.** READMEs use neutral names (`blog`, `acme`). The pre-save seam, `sm-moderation-plugin` and every `vue-*-layer` follow this. `golem15.demo` may name itself.
- **Core plugins: no breaking changes** (user, blog, pages, payment… and here chat/forum: WavePath parity, Phase 31). Seams are additive and ship as patch tags (D-18).
- **Submodule commit order:** commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. One `chore: bump <plugin> to <tag>` per plugin.
- **go.mod requires tags only** (`TestGoModRequiresTags`, `scripts/check-pins.sh`); `go.work` handles local checkouts.
- **Module/plugin READMEs** follow the standard structure (H1, summary, import, `**Name:**` mnemonic, Overview, Features, Usage, API reference, Configuration, CLI, Dependencies, Testing); every identifier must exist (docs checker for framework; plugin READMEs by hand).
- **Lean planning:** few, large plans; present the plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** STATE.md via `msd_run query state.*` handlers. g15office sync on phase advance.
- **Commits:** no co-author tags (user's global CLAUDE.md overrides the harness attribution); stage files by explicit path; planning docs and code in separate commits. Don't push, tag or deploy without the user's go-ahead.
- **Pushed release tags `vX.Y.Z`** of the framework get a changelog post on summercms.io the same day (only if this phase cuts a framework tag — not expected).
- **Memory:** tag only at phase completion for the framework (interim plugin tags are fine). Add new shared plugins to the demo (this phase's whole point).
- **Docker tests:** if the Docker socket is denied, run through `sudo -u golem`.
## Repo State and Tagging (verified this session)
| Plugin | Where current code lives | Origin `main` | Tags on origin | go.mod problems for module mode |
|--------|-------------------------|---------------|----------------|----------------------------------|
| `sm-chat-plugin` | `sm-bm-app/plugins/golem15/chat` (51c9e17 = v0.1.3) | 51c9e17 | v0.1.0, v0.1.1, v0.1.2, v0.1.3 | none (requires user v0.1.2, summercms v0.4.0) |
| `sm-chatchannels-plugin` | `sm-bm-app/plugins/golem15/chatchannels` (a30f04a) | a30f04a | **none** | none (requires chat v0.1.0) |
| `sm-chatbots-plugin` | `sm-bm-app/plugins/golem15/chatbots` (d0b9fb1 "drop sibling replaces") | d0b9fb1 | **none** | none (requires chat v0.1.0) |
| `sm-chatvideo-plugin` | `../sm-chatvideo-plugin` (be688fe) | be688fe | **none** | `require sm-chat-plugin v0.0.0`; `replace sm-chat-plugin => ../sm-chat-plugin`; `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
| `sm-forum-plugin` | `../sm-forum-plugin` (2779341) | 2779341 | **none** | none |
| `sm-herald-plugin` | `../sm-herald-plugin` (74c889c) | 74c889c | **none** | `replace sm-user-plugin => ../fonoteka.go/plugins/golem15/user` |
| `sm-userfriends-plugin` | `../sm-userfriends-plugin` (57db2ba) | 57db2ba | **none** | requires chat pseudo-version `v0.0.0-20261017200243-4d0e764ca443` |
[VERIFIED: `git ls-remote origin` per repo; `git -C … log`; go.mod files read]
- **Sibling checkouts are stale:** `../sm-chat-plugin` is at 5fea654 (origin 51c9e17). `../sm-chatchannels-plugin` is at f3ac5d1 (origin a30f04a). `../sm-chatbots-plugin` is at 9dfe8b7 (origin d0b9fb1). Fast-forward (`git pull --ff-only`) before any edit.
- **Compatibility proof:** in a scratch `go.work` I used framework v0.5.1 (from `git archive v0.5.1`) and user v0.1.13 (the demo's submodule), set every chat-family go.mod to `sm-chat-plugin v0.1.3`, `sm-user-plugin v0.1.13` and `summercms v0.5.1`, and removed the sibling replaces. All seven pass `go build ./...`, `go vet ./...` and `go test -short ./...`. Before the go.mod fix, every build failed with `git.golem15.com/golem15/sm-chat-plugin@v0.0.0: reading … go.mod at revision v0.0.0: unknown revision v0.0.0`. [VERIFIED: scratch build output]
- **Suggested tags** (one-way, D-03 of 19.4.1):
| Plugin | Tag |
|--------|-----|
| chat | v0.1.4: pre-save seam, additive (patch) |
| chatchannels | v0.1.0 |
| chatbots | v0.1.0 |
| chatvideo | v0.1.0, after the go.mod fix |
| forum | v0.1.0, with the seam (its first tag) |
| herald | v0.1.0, after dropping the replace |
| userfriends | v0.1.0, chat requirement → v0.1.3 or v0.1.4 |
| sm-moderation-plugin | v0.1.0 |
Raising the minimal `summercms` / `sm-user-plugin` requires in plugin go.mod files is optional (MVS picks the demo's v0.5.1 / v0.1.13). Keep the `replace git.golem15.com/golem15/summercms => ../summercms.go` line, the standard plugin pattern.
- **bm is unaffected:** `sm-bm-app` pins chatchannels and chatbots as `v0.0.0` with local `replace` lines. It keeps building whatever the new tags are.
- **Folded todo (oauthserver):** the `sm-demo-app` submodule is at b6a9586 (`v0.1.2-7-gb6a9586`, uncommitted move). The index has 8bbec72 = v0.1.2, and go.mod requires v0.1.2. Tag v0.1.3 = df2eddd ("require SummerCMS v0.5.0 and sm-user-plugin v0.1.11") contains b6a9586. Move the checkout to v0.1.3, run `GOWORK=off go get git.golem15.com/golem15/sm-oauthserver-plugin@v0.1.3` then `go mod tidy`, and commit `chore: bump sm-oauthserver-plugin to v0.1.3`. The journal half of the todo is already done (`11d485f chore: bump journal to v0.2.5`). [VERIFIED: `git submodule status`, `git log`]
## Standard Stack
### Core (backend, all already chosen in Phases 19/19.1)
| Library / module | Version | Purpose | Why Standard |
|------------------|---------|---------|--------------|
| `git.golem15.com/golem15/summercms` | v0.5.1 (demo pin; v0.5.2 exists) | Framework | Demo already on it; chat family proven on it this session |
| `sm-chat-plugin` | v0.1.3 → v0.1.4 (seam) | Conversations, messages, reactions, attachments | Phase 19 port |
| `sm-chatchannels-plugin` | first tag v0.1.0 | Channels, pins, mentions | Phase 19 port |
| `sm-chatvideo-plugin` | first tag v0.1.0 | LiveKit sessions, tokens, reconcile | Phase 19.1 |
| `sm-chatbots-plugin` | first tag v0.1.0 | Slash command registry/dispatch | Phase 19.1 |
| `sm-forum-plugin` | first tag v0.1.0 (seam) | Forum | Phase 19.1 |
| `sm-herald-plugin` | first tag v0.1.0 | Web Push (mounted, no UI) | Phase 19.1 |
| `sm-userfriends-plugin` | first tag v0.1.0 | Friends, groups | Phase 19.1 |
| `github.com/livekit/server-sdk-go/v2` | v2.19.0 (transitive via chatvideo) | RoomService client | Already in chatvideo go.mod |
| LiveKit server | 1.13.1 (installed on rome `/usr/local/bin/livekit-server` and locally) | SFU | Same binary WavePath runs; no install needed |
| Centrifugo | v6.5.1 (rome and local) | Realtime | Already the demo's (D-17) |
### Frontend
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `nuxt` | 4.4.8 (pinned across layers) | Layers and app | All layers `peerDependencies: { nuxt: 4.4.8 }` |
| `vue` | 3.5.35 | — | — |
| `centrifuge` | 5.7.3 (already pinned in vue-demo-app; audited in 19.4.1) | Centrifugo client | Move/duplicate into `vue-chat-layer` deps at the same pin |
| `livekit-client` | 2.23.0 latest (published 2026-10-16) `[WARNING: flagged as suspicious — verify before using.]` | WebRTC client for `vue-chatvideo-layer` | Pin an exact version after the human-verify checkpoint |
| `vitest` | 3.2.7 (vue-demo-app's pin) | Layer unit tests for the ported bm tests | bm's chat tests are vitest |
**No Pinia.** 19.4.1 established "Pinia stores become `useState` composables" (19.4.1-PATTERNS.md:259; 19.4.1-04-PLAN.md:134). `@pinia/nuxt` 1.0.3 peers `pinia ^4.1.0` and depends on `@nuxt/kit ^4.6.0`, against the repo's `nuxt` 4.4.8. bm's store is a setup store (`defineStore('chat', () => {…})`), so converting it to a module-level factory or composable is mechanical. [VERIFIED: npm view @pinia/nuxt peerDependencies dependencies]
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Own LiveKit instance on rome | Add a key pair to WavePath's LiveKit (`/etc/livekit/livekit.yaml`) | Any API key can list/delete every room on that server; cross-tenant blast radius; D-10 says own keys → use own instance |
| UDP mux (`rtc.udp_port`) | Port range (`port_range_start/end`) | Range needs a wide firewall hole and overlaps WavePath's 50000-60000; mux needs one port |
| Embedded TURN/TLS on 443 via nginx `stream` `ssl_preread` | No TURN, ICE-TCP fallback (`rtc.tcp_port`) | `ssl_preread` would restructure every 443 vhost on a shared host; ICE-TCP covers most NAT'd browsers |
| Festival "-ing" event as pre-save seam | Validator registry on the services (`Service.AddValidator`) | Both additive; festival mirrors `user.Deleting`/`user.Deactivating` and needs no new type on the service |
**Installation (layers):**
```bash
# vue-chat-layer
pnpm add centrifuge@5.7.3
pnpm add -D vitest@3.2.7 nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
# vue-chatvideo-layer (after checkpoint:human-verify on livekit-client)
pnpm add livekit-client@<pinned>
```
**Version verification:** `npm view livekit-client version` → 2.23.0 (2026-10-16). `npm view centrifuge version` → 5.8.0 (2026-10-20); the demo stays on the audited 5.7.3. [VERIFIED: npm registry]
## Package Legitimacy Audit
| Package | Registry | Age (latest) | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| `livekit-client` | npm | latest 12 days | 5.67M/wk | github.com/livekit/client-sdk-js | [SUS] reason `too-new` | Flagged — planner must add checkpoint:human-verify; pin an exact version |
| `centrifuge` | npm | latest 8 days | 1.61M/wk | github.com/centrifugal/centrifuge-js | [SUS] reason `too-new` (latest only) | Keep the already-audited 5.7.3 pin from 19.4.1; no new install decision |
| `pinia`, `@pinia/nuxt` | npm | — | — | github.com/vuejs/pinia | [SUS] `too-new` | Not recommended (see above) |
No postinstall scripts on `livekit-client` or `centrifuge` (`npm view … scripts.postinstall` empty). `centrifuge` pulls `protobufjs`, whose postinstall is already disabled in `pnpm-workspace.yaml` (`protobufjs: false`).
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** `livekit-client`: the planner inserts checkpoint:human-verify before the install. The "too-new" verdict is about the latest release, not the package. It is LiveKit's official JS SDK, but its name came to me from training and the chatvideo README, not from Context7, so treat it as `[ASSUMED]` until verified.
## Architecture Patterns
### System Architecture Diagram
```
Browser (vue-demo-app + layers, client-only chat/video)
│ JWT (DEMO cookie) as Bearer
├─ GET /_demo/api/v1/chat ─────────────► golem15.demo bootstrap ──► chat.Service.GetConversationsForUser
│ chatchannels.ChannelService (channels, mention counts)
├─ /_chat/api/v1/... (send/edit/react/replies/upload) ──► chat.Service
│ └─ [NEW] Fire MessageSaving (pre-save) ──► demo listener ──► moderation.Filter.Check ──► 422 or continue
│ └─ tx: insert ─► Fire MessageCreated ─► chatchannels mentions; lighthouse Bind ─► broadcasts queue ─► Centrifugo publish
├─ /_chatchannels/api/v1/contexts/demo/1/channels/... (join, pins, mentions)
├─ /_chatbots/api/v1/commands | /dispatch ─► chatbots Registry (demo-registered commands, ephemeral answers)
├─ /_forum/api/v1/contexts/demo/1/... ─► forum.Service ─► [NEW] Fire PostSaving ─► moderation ─► persist (breakwater render)
├─ /_userfriends/api/v1/...
├─ /_chatvideo/api/v1/conversations/{dm}/video/start ─► chatvideo (participants ≤ max_participants=2 ⇒ DMs only)
│ └─ RoomAPI ─► http://127.0.0.1:<lk-signal> (loopback, TrustedMode) ; returns {token, livekit_url}
├─ GET /api/realtime/token ─► demo guard (any member) ─► lighthouse token
├─ wss /ws ─► nginx ─► Centrifugo (demo) ──subscribe proxy──► Go authorizers: chat / user / video / presentations
└─ wss /livekit/rtc ─► Cloudflare ─► nginx ─► LiveKit (demo instance) signal
media: UDP <mux port> / TCP <ice-tcp port> direct to rome public IP (bypasses Cloudflare)
```
### Recommended Project Structure
```
sm-moderation-plugin/ # new shared plugin (golem15.moderation)
├── plugin.go admin.go # pact.HasSettings, HasMigrations, HasConfig, HasLang
├── classes/filter.go # Filter.Check(ctx, text) -> *Violation (words, links)
├── models/settings.go + models/settings/fields.yaml
├── updates/ lang/ config/
sm-demo-app/plugins/demo/
├── chat.go # registry aliases "demo" + "channel", resolvers, limits, seam listeners, chatbots commands
├── classes/ (or chat_context.go) # DemoContext, ChannelContext adapter, access resolvers (chatchannels, forum)
├── chat_api.go # GET /_demo/api/v1/chat, /chat/members (bootstrap)
├── console/seed_chat.go seed_forum.go seed_friends.go
vue-chat-layer/ # new
├── app/components/chat/… # ported + ChannelList, ThreadPane, PinsPanel, CommandPicker
├── app/composables/useChat.ts # ported store (no Pinia)
├── app/lib/realtime.ts # shared Centrifuge client + onUserEvent hook (used by chatvideo layer)
├── app/lib/chat{Events,Format,Text,Unread}.ts (+ tests)
├── app/pages/chat.vue # optional; or app provides page and renders <ChatPage>
vue-chatvideo-layer/ # extends vue-chat-layer
vue-forum-layer/ vue-userfriends-layer/
```
### Pattern 1: Host context and channel-as-context adapter (golem15.demo)
**What:** Register two aliases on `*chatcontracts.Registry`:
- `demo`: one context, id 1. Every registered, non-deleted, non-banned user is a member.
- `channel`: loads a `chatchannels` channel by id. Its members are the demo community's members, since every channel is public (D-07).
Install `chatchannels` `SetAccessResolver` (any member may access `demo/1`, nobody manages it) and `forum.SetAccessResolver`.
**Why the `channel` alias is required:** `CreateChannel` writes the backing conversation with `ContextType: &alias` where `alias := models.ChannelAlias` (`"channel"`) [VERIFIED: sm-bm-app/plugins/golem15/chatchannels/classes/channel_service.go:168-174]. No production code registers that alias. The only `Alias: models.ChannelAlias` registration is the test fixture [VERIFIED: chatchannels/fixture_plugin_test.go:172]. Without it, chat resolves the conversation's context to `ErrUnknownContextType` (404).
**Lazy join:** `JoinChannel` only upserts a pref row (`row.HasLeft = false`) and adds no chat participant [VERIFIED: channel_service.go:959-969]. Sending needs `isParticipant` [VERIFIED: chat/classes/service.go:463-468], while reading falls back to `host.HasMember` [VERIFIED: service.go:630-640]. The client gets participant rows by calling `GET /_chat/api/v1/contexts/channel/{channelId}/conversation`, where `GetGroupChat` "still lazy-joins a context member" (chat README). Alternatively, the demo bootstrap adds the participant. Choose one and test it.
**Example (mirror bm):**
```go
// Source: sm-bm-app/plugins/jz/bm/chat.go:35-90 (pattern), adapted
func (p *Plugin) registerChat(app *backpack.App) error {
reg, ok := app.Lookup[*chatcontracts.Registry]()
if !ok || reg == nil { return errors.New("golem15.demo: golem15.chat must boot first") }
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: "demo", Loader: p.loadDemoContext}); err != nil { return err }
if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: chanmodels.ChannelAlias, Loader: p.loadChannelContext, LegacyPHPClass: chanmodels.ChannelMorphName}); err != nil { return err }
return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error {
ch, _ := app.Lookup[*chanclasses.ChannelService]()
ch.SetAccessResolver(p.channelAccess) // CanAccessContext: member && demo/1; CanManageContext: false
f, _ := app.Lookup[*forum.Plugin]()
f.SetAccessResolver(p.forumAccess) // CanRead/CanPost: member; CanManage: moderator group
// chatbots registry is published inside its own OnDatabase hook (chatbots/boot.go bootService)
bots, _ := app.Lookup[*botclasses.Registry]()
return registerDemoCommands(bots)
})
}
```
`BroadcastChannel()` must be non-empty for both contexts ("an empty host channel silences the conversation channels too", bm chat_context.go:31-35). Use names no client can subscribe to, for example `chat:context:1` and `chat:channel:{id}`. `AuthorizeChat` admits only `chat:conversation:{id}`.
Implement `contracts.LiveMembership` (`RequireLiveMembership() true`) and `contracts.MessageModerator`, as bm does, so that banned users lose access at the next check and a moderator group can delete messages in group conversations.
### Pattern 2: Additive pre-save seam (D-18), recommended shape
**What:** Use a festival "-ing" event, fired **before** the write transaction, mirroring `user.Deleting` / `user.Deactivating` (sm-user-plugin/classes/events.go: "A listener error rolls the deactivation back").
- chat: `events.MessageSaving{ConversationID, ActorID, RootID, Content, Metadata, Op}`, where `Op` is `"create"` or `"update"`. Fire it in `SendMessageWith` after the participant and empty checks, and in `EditMessage` after `CanEditMessage`.
- forum: `events.PostSaving{ContextType, ContextID, ActorID, ThreadID, PostID, Title, Body, Op}`. Fire it in `CreateThread`, `Reply` and `EditPost` before `breakwater.Render`.
- A listener rejects with a typed error, for example `contracts.Rejection{Field, Message}`, implementing `error`. The services return it wrapped. The API maps `errors.As(err, &contracts.Rejection{})` to the **existing** 422 envelope `{"error":"Validation failed","errors":{field:[message]}}` (chat api/errors.go:39-44, forum api/errors.go). Every existing route, payload and status stays the same. Only a request a listener rejects gets the new 422.
- With no listener, behaviour is byte-identical (WavePath parity; the PHP method map tests stay green).
**Why not the existing events:** `MessageCreated` fires inside the transaction after `tx.Create(msg)` (service.go SendMessageWith). A listener error there rolls back, but it maps to 500 (the `writeChatError` default) and burns an id. **Forum fires no festival events at all.** `grep "Fire("` over sm-forum-plugin finds none, and `ForumService` has a `Bus` field (classes/forum_service.go:57-58) that is never used. CONTEXT's "chat and forum fire their festival events after the save" is accurate only for chat.
**Coupling:** `sm-moderation-plugin` must not import chat or forum. It publishes a `*classes.Filter`, and `golem15.demo` (which already imports both) wires it:
```go
// Source: pattern from festival bus.go:43 (Listen[T]) and user events
app.Events.Listen[*chatevents.MessageSaving](p.ID(), func(ctx context.Context, ev *chatevents.MessageSaving) error {
if v := filter.Check(ctx, ev.Content); v != nil {
return chatcontracts.Rejection{Field: "content", Message: v.Message}
}
return nil
})
```
Note: `festival.Bus.Fire` returns `errors.Join` of the listener errors (bus.go:74-75), so callers must use `errors.As`, not `==`.
### Pattern 3: One shared realtime connection across layers
bm's `lib/realtime.ts` keeps a single Centrifuge client (`connect`, `subscribe`, `presence`). The chat store subscribes to `user:{me}` for the whole session. chatvideo publishes `video:call:incoming` to `user:{id}` (chatvideo README). One client cannot hold two subscriptions to the same channel, so `vue-chatvideo-layer` must **not** open its own `user:{me}` subscription. Put the realtime client in `vue-chat-layer` and add `onUserEvent(handler)` so unknown `user:` events (`video:*`) reach other layers. `vue-chatvideo-layer` `extends` `vue-chat-layer` and subscribes to `video:session:{id}` through the same client.
### Pattern 4: Layer conventions (from vue-faq-layer, vue-paymentgateway-layer, vue-oauth-layer)
- `nuxt.config.ts` builds absolute paths from `fileURLToPath(new URL('.', import.meta.url))`.
- runtimeConfig has a server `apiBase` (`NUXT_<X>_API_BASE`) and a public same-origin `apiBase`.
- `peerDependencies: { nuxt: 4.4.8 }` and `engines.node >=22.18`.
- Tests run with `node --test --test-reporter=tap tests/*.test.ts`.
- Each layer is consumed as `"vue-x-layer": "git+https://git.golem15.com/golem15/vue-x-layer.git#vX.Y.Z"`.
- **Auth hook:** the app provides a token getter through a Nuxt plugin (`provide: { paymentGatewayAuth: () => useCookie(DEMO_TOKEN_COOKIE).value ?? null }`, vue-demo-app/app/plugins/auth.ts). The chat layer needs more than a getter: a 401 refresh (bm's client calls `/_user/api/v1/fetch` to renew) and an `onUnauthorized`. Define a `chatAuth` hook with `token()`, `refresh()` and `unauthorized()`, or let the app inject the whole HTTP client.
- **Host bootstrap contract:** the layer reads `public.chat.hostApiBase` (for example `/_demo/api/v1`) and documents the `GET {hostApiBase}/chat` and `/chat/members` JSON shapes as the layer's contract. Any host implements them, so the layer names no app.
- **SSR:** `routeRules` `ssr: false` for `/chat/**` and the call page. The floating panel goes in `<ClientOnly>` in the app's layout, because a layer cannot inject into the app's layout.
### Pattern 5: DM-only calls enforced server-side (D-11)
`VideoService.Start` checks only that the actor is a participant and that the conversation's participant count is at most `golem15.chatvideo.max_participants` (`max := s.intKey("golem15.chatvideo.max_participants", 8); if max > 0 && len(memberIDs) > max { return nil, ErrTooMany }`) [VERIFIED: sm-chatvideo-plugin/classes/video_service.go:109-112]. Conversation type is not checked, so **the plugin does not restrict calls to DMs.** `GetOrCreateDm` creates two-person DMs only. With `max_participants: 2` and every seeded channel holding three or more participants, `Start` on any channel or group conversation answers 422 `errTooMany` [VERIFIED: chatvideo/api/errors.go:42-43]. That is server-side enforcement of D-11 with no plugin change. The UI shows the call button only in DMs. LiveKit's `room.max_participants: 4` is a second cap. `Join` does not check counts (video_service.go:171-195), but only conversation participants can get a session (`sessionForActor`).
### Anti-Patterns to Avoid
- **Seeding channels through `CreateChannel` with the demo's resolver.** It is gated by `CanManageContext` [VERIFIED: channel_service.go:136], which the demo answers false (D-07). Seed with a separate trusted instance instead, `chanclasses.New(chanclasses.Options{DB: tx, Resolver: seedResolver})`, or with raw SQL in the existing seed style.
- **Seeding chat messages through `chat.Service`.** Its `SendMessageWith` emits realtime and enqueues jobs inside the tx. The seed already uses raw SQL (`insertID`, `exists`) and stays idempotent.
- **Announce-mode chatbots commands.** HTTP dispatch passes `classes.CommandContext{}` [VERIFIED: chatbots/api/commands.go:57], so `announce` never persists (`cmdCtx.ConversationID == 0` returns early, dispatcher.go:52-56). Demo commands must be `Ephemeral`.
- **Opening a second Centrifuge client** in the video layer (see Pattern 3).
- **Editing `../vue-demo-app`.** That sibling checkout is stale (32049f7, no remote). The real app is the submodule `sm-demo-app/vue-demo-app` (44a5229, origin `golem15/vue-demo-app`).
- **Putting the moderation filter in `golem15.demo`.** The user asked for a shared plugin (CONTEXT specifics).
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Chat persistence, permissions, unread, reactions, tombstones | Demo-side message tables or endpoints | `sm-chat-plugin` generic API `/_chat/api/v1` | PHP-parity contracts; WavePath target |
| Channel pins, mentions, joins | Custom pin/mention tables | `/_chatchannels/api/v1/contexts/demo/1/channels/{id}/pins`, `/mentions` | Already ported, with mention accrual listeners |
| Video tokens / room cleanup | Own JWT minting or room GC | `chatvideo` `Start/Join/Leave/End` + `chatvideo:reconcile-sessions` (every 5 min) + LiveKit `room.empty_timeout` | HS256 golden tested |
| WebRTC | Raw `RTCPeerConnection` | `livekit-client` | SFU signalling, reconnection, simulcast |
| Realtime | Own WebSocket server | Centrifugo + lighthouse authorizers | Already deployed |
| Markdown → safe HTML for forum | Client-side markdown rendering of user input | Server `content_html` from `breakwater.Render` (stored on write) | Sanitized server-side; render with `v-html` only from that field |
| Web Push | Custom VAPID | `herald` + `herald:vapid-generate` | — |
| Settings screen for word lists | Custom admin page | `pact.HasSettings` + `fields.yaml` singleton (pattern: sm-feedback-plugin admin.go) | cabana renders it |
| Firewall changes on rome | Raw `iptables` | Vuurmuur (`vuurmuur_conf`/`vuurmuur_script`): rome's INPUT chain is Vuurmuur-managed (`vrmr:` NFLOG prefixes) | Raw rules get overwritten on Vuurmuur reload |
**Key insight:** The chat family is complete server-side. The work is host glue (context, adapter, resolvers, bootstrap), release hygiene and UI. Adding parallel endpoints or tables would fork the WavePath contract.
## Common Pitfalls
### Pitfall 1: Stale sibling checkouts
**What goes wrong:** Editing `../sm-chat-plugin` (13+ commits behind) and tagging an old tree, or a seam patch that conflicts with v0.1.3.
**How to avoid:** `git -C ../sm-chat-plugin pull --ff-only` (same for chatchannels and chatbots) as task 1, then assert `git rev-parse HEAD == $(git ls-remote origin refs/heads/main)`.
### Pitfall 2: `v0.0.0` and pseudo-version requires break module mode
**What goes wrong:** `GOWORK=off go build` in sm-demo-app fails with `unknown revision v0.0.0` (verified).
**How to avoid:** Fix chatvideo and userfriends go.mod before tagging. `TestGoModRequiresTags` checks only the app's own go.mod, not transitive ones, so also build `GOWORK=off` in a clean temp dir.
### Pitfall 3: Missing `channel` alias, so messages in seeded channels 404
**How to avoid:** Pattern 1. Test sending to a seeded channel conversation through the HTTP API.
### Pitfall 4: Realtime token route limited to presentation owners
**What goes wrong:** Chat users get 403 from `/api/realtime/token`. The demo mounts `UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime)` and mounts nothing unless presentations is active [VERIFIED: sm-demo-app/plugins/demo/routes.go:78-87]. The vue-demo-app realtime client treats 401/403/404 as final (`UNAVAILABLE = new Set([401, 403, 404])`, app/lib/realtime.ts) and stops for good.
**How to avoid:** Port bm's `jz.bm.realtime-access` guard (sm-bm-app/plugins/jz/bm/realtime_access.go): a demo member passes, otherwise the presentations owner guard decides.
### Pitfall 5: Centrifugo namespaces missing
**What goes wrong:** Subscribes to `chat:…`, `user:…` and `video:…` fail. The demo config has only `presentations` (deploy/centrifugo.json.example).
**How to avoid:** Add `chat` (`presence`, `join_leave`, `force_push_join_leave`, `allow_presence_for_subscriber`, subscribe proxy), `user` and `video` (subscribe proxy), copying bm's namespace block (sm-bm-app/deploy/centrifugo/bm-config.json). "`centrifugo checkconfig` does not catch misspelt option names" (sm-bm-app DEPLOY.md:395). Compare against `centrifugo defaultconfig`. The `user` authorizer comes from sm-user-plugin v0.1.13 (`RegisterUserAuthorizer`, classes/authorizer.go:35-47). Restart `summercms-demo-centrifugo` after changing `centrifugo.json`.
### Pitfall 6: LiveKit media ports not open on rome
**What goes wrong:** Signalling works but the call shows no remote video (ICE fails). rome's INPUT policy is DROP under Vuurmuur. A Vuurmuur service `Livekit` exists (`RANGE="6;7881*1024:65535"`, `RANGE="17;50000:60000*1024:65535"`) but no rule in `/etc/vuurmuur/rules/rules.conf` uses it, and `iptables -S` shows no rule for 7881 or 50000-60000 [VERIFIED: read-only ssh probe 2026-10-28].
**How to avoid:** Define a demo service in Vuurmuur (TCP ICE port plus UDP mux port) and a rule allowing it from the internet to the firewall, then apply it through Vuurmuur. This needs the user's go-ahead (shared production host). Verify with `ss -lunp` and an external `nc -u` / a real two-browser call.
### Pitfall 7: Port collisions with WavePath's LiveKit
**What goes wrong:** LiveKit defaults (7880, 7881, 50000-60000) collide with the running `wavepath-livekit` program.
**How to avoid:** Use distinct ports: for example signal `127.0.0.1:7890`, `rtc.tcp_port: 7891`, `rtc.udp_port: 7892` (mux, no `port_range_*`). Check them free with `ss -ltnup` at deploy time. The ss probe today showed 7890-7892 unused. Extend `scripts/check-deploy.sh` port agreement checks to LiveKit.
### Pitfall 8: LiveKit behind Cloudflare and nginx
**What goes wrong:** The signal WebSocket drops or the URL path is wrong.
**How to avoid:**
- Keep the signal on the same origin under a prefix, for example `livekit_url: wss://demo.summercms.io/livekit`. livekit-client appends paths to the existing pathname ("`wss://host/livekit` becomes `wss://host/livekit/rtc`", client-sdk-js src/api/utils.ts `appendUrlPath`) [CITED: github.com/livekit/client-sdk-js/blob/main/src/api/utils.ts].
- nginx: `location ^~ /livekit/ { proxy_pass http://127.0.0.1:7890/; proxy_http_version 1.1; Upgrade/Connection headers; proxy_read_timeout 7d; proxy_buffering off; }`, as lk.wavepath.org does.
- Set `livekit_admin_url: http://127.0.0.1:7890` so the Go RoomService calls stay on loopback. fetchguard TrustedMode allows loopback.
- Media goes direct to the public IP, so ICE candidates reveal rome's IP (54.36.173.176) despite Cloudflare. It is accepted risk, since rome already exposes game servers on that IP.
- Newer livekit-client tries `/rtc/v1` and falls back to `/rtc`. Server 1.13.1 support is `[ASSUMED]`. Test locally against the same 1.13.1 binary.
### Pitfall 9: Floating panel and SSR
**What goes wrong:** Hydration mismatches or `window` errors.
**How to avoid:** Render the floating panel in `<ClientOnly>`. Set `ssr: false` for `/chat/**` and the call route. Load `livekit-client` with a dynamic `import()`, as `centrifuge` is in realtime.ts.
### Pitfall 10: Reset leaves LiveKit rooms and chat rows behind
**What goes wrong:** `demo:reset` deletes `users`. Chat tables cascade from users in parts only, `golem15_chatchannels_*` rows have no FK to messages, and active LiveKit rooms survive.
**How to avoid:** Add every chat-family table to `resetTables` in FK order, children first, before `users`:
- chatvideo participants and sessions
- chat reactions, thread mutes, thread summaries, messages, participants and conversations
- chatchannels mentions, pins, members, prefs, user context prefs, personal categories, channels and categories
- forum reads, posts, threads and categories
- userfriends bans, invites, group members, groups and friendships (keep the singleton context id=1)
- herald subscriptions
- chatbots bot flags
Keep `golem15_moderation_settings`. Set LiveKit `room.empty_timeout` and `departure_timeout` so orphaned rooms close. `TestResetKeepsPlans` must grow to cover the new tables.
### Pitfall 11: robots.txt and allow-lists drift
**What goes wrong:** New API prefixes are not routed by nginx, or they show up in robots.txt.
**How to avoid:** Add the new paths in three places:
- nginx Go allow-list: `/_chat/`, `/_chatchannels/`, `/_chatvideo/`, `/_chatbots/`, `/_forum/`, `/_userfriends/`, and `/livekit/` to LiveKit.
- `vue-demo-app` `goPaths`: dev proxy and robots.
- The SEO plugin's robots.txt in the admin (DEPLOY.md "SEO": the Disallow lines are copied by hand).
`/_herald/` stays unrouted: there is no UI (D-16).
### Pitfall 12: Forum and friends APIs are JWT-only
Every forum route is behind `jwt.auth` (forum README). An anonymous `/forum` cannot list threads without a new public endpoint. See Open Question 3.
### Pitfall 13: No admin screens for chat/forum content
None of the seven plugins implements `HasAdminControllers` or `HasSettings` (grep). D-17's "admins deleting messages … from the backend" has no backend UI. See Open Question 2.
## Code Examples
### Demo context (verbatim interface members bm implements)
```go
// Source: sm-bm-app/plugins/jz/bm/classes/chat_context.go:12-17,101-110
var (
_ chatcontracts.Context = (*ChatContext)(nil)
_ chatcontracts.MessageModerator = (*ChatContext)(nil)
_ chatcontracts.LiveMembership = (*ChatContext)(nil)
_ chancontracts.AccessResolver = (*ChatAccess)(nil)
)
func (c *ChatContext) ID() uint { return ChatContextID }
func (c *ChatContext) Type() string { return ChatContextType }
func (c *ChatContext) ChatEnabled() bool { return true }
func (c *ChatContext) GroupName() string { return ChatGeneralName }
func (c *ChatContext) BroadcastChannel() string { return ChatBroadcastChannel }
// + CanActFor, CanOverseeChats, CanModerateMessage, RequireLiveMembership, HasMember, Members
```
### Chatbots command (ephemeral)
```go
// Source: contracts in sm-bm-app/plugins/golem15/chatbots/contracts/slash_command.go
type demoCommand struct{}
func (demoCommand) Trigger() string { return "demo" }
func (demoCommand) Description() string { return "What this demo shows" }
func (demoCommand) IsAvailable(actor uint, _ contracts.CommandContext) bool { return actor != 0 }
func (demoCommand) Handle(args string, actor uint, _ contracts.CommandContext) contracts.CommandResult {
return classes.Ephemeral("This is the SummerCMS demo: chat, channels, calls, forum …")
}
```
Catalog JSON: `GET /_chatbots/api/v1/commands` → `{"commands":[{"trigger","description"}]}`. Dispatch: `POST /dispatch {"input":"/demo"}` → `{"mode","payload"}`; unknown → 422 `errors.input` [VERIFIED: chatbots/api/commands.go:21-66; classes/dispatcher.go:89-99].
### Chatvideo config for the demo
```yaml
# config/golem15.chatvideo.yaml (keys verbatim from sm-chatvideo-plugin/config/config.yaml:4-11)
livekit_url: "wss://demo.summercms.io/livekit"
livekit_admin_url: "http://127.0.0.1:7890"
max_duration: 900 # D-12 ≈15 min (reconcile runs every 5 min, so up to ~20)
max_participants: 2 # DM-only calls server-side (Pattern 5)
token_ttl: 900
screen_share: false # minimal call page (D-13)
# secrets: SUMMER_GOLEM15__CHATVIDEO__LIVEKIT_API_KEY / __LIVEKIT_API_SECRET in .env (or PHP names LIVEKIT_API_KEY/SECRET)
```
### LiveKit config for the demo instance (keys from LiveKit config-sample)
```yaml
# /srv/summercms-demo/livekit.yaml (root:summercms-demo 0640)
port: 7890
bind_addresses: [127.0.0.1]
rtc:
tcp_port: 7891
udp_port: 7892 # UDP mux; port_range_start/end must NOT be set
use_external_ip: true
room:
max_participants: 4
empty_timeout: 120
departure_timeout: 20
keys:
CHANGE_ME_KEY: CHANGE_ME_SECRET
```
[CITED: raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml]. The `udp_port` comment there says "port_range_start & end must not be set for this config to take effect". `bind_addresses` is used by rome's working WavePath config [VERIFIED: /etc/livekit/livekit.yaml on rome].
### Supervisor program (pattern from rome's wavepath-livekit.conf)
```ini
[program:summercms-demo-livekit]
command=/usr/local/bin/livekit-server --config /srv/summercms-demo/livekit.yaml
directory=/srv/summercms-demo
user=summercms-demo
autostart=true
autorestart=true
startsecs=5
stopsignal=INT
stopwaitsecs=10
redirect_stderr=true
stdout_logfile=/var/log/supervisor/summercms-demo-livekit.log
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| bm SPA: one channel + DMs, host bootstrap | Multi-channel sidebar over chatchannels + host bootstrap | This phase | New store shape (`channels[]` replaces `general`) |
| Pinia stores | `useState`/module composables | 19.4.1 | No Pinia in layers |
| LiveKit port range | UDP mux single port | LiveKit ≥1.x | One firewall hole |
| livekit-client `/rtc` | `/rtc/v1` with v0 fallback | client 2.17+ (per community reports) | Test against server 1.13.1 |
**Deprecated/outdated:**
- The sibling `../sm-chat-plugin`, `../sm-chatchannels-plugin` and `../sm-chatbots-plugin` trees are behind origin. Treat origin `main` as the truth.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `livekit-client` (latest 2.x) connects to LiveKit server 1.13.1 through `/livekit` prefix behind Cloudflare | Pitfall 8 | Calls fail; fallback: dedicated `lk.demo.summercms.io` host (DNS + cert) |
| A2 | UDP mux + ICE-TCP without TURN is enough for "typical NAT'd browsers" | Alternatives | Some corporate networks fail; fallback: embedded TURN on UDP 3478 / TLS 5349 with a DNS-only hostname |
| A3 | Chat `GetGroupChat` on alias `channel` lazy-joins a channel member as participant (README statement; not traced through the HTTP handler this session) | Pattern 1 | Users cannot post in channels; fallback: bootstrap adds participants |
| A4 | Forum `PostSaving` and chat `MessageSaving` names/shape | Pattern 2 | Discretion area; any additive shape is fine |
| A5 | Persona users without passwords are acceptable (no login) | Seeding | None material |
| A6 | Centrifugo v6 namespace option names copied from bm's running config are correct for the demo's config format (`channel.proxy.subscribe` + `subscribe_proxy_enabled`, unlike bm's named `proxies`) | Pitfall 5 | Subscribes denied; `centrifugo checkconfig` + `defaultconfig` comparison catches most |
| A7 | `livekit-client` is the official LiveKit package (name from chatvideo README/training, registry repo github.com/livekit/client-sdk-js) | Legitimacy | Human-verify checkpoint covers it |
## Open Questions (RESOLVED)
Resolved 2026-10-28: Q1 by the 19.4.1 decision (admin at `/smbackend`, `/backend` 404; restated in plan 05's deploy checkpoint); Q2 by D-22 (moderator group, admin screens deferred); Q3 by D-21 (register prompt); Q4 by plans 03/04 (threads, pins, linkified URLs and an embed slot).
1. **Is the admin public or not?**
- What we know: ROADMAP's goal says "The admin backend is not public". 19.4.1-02's DECISION CHANGE (user, 2026-10-20) made the admin public at `/smbackend` with a login required, throttled to 5/min, while `/backend` returns 404. SC-3 says "`/backend` is not reachable". That holds literally.
- Recommendation: keep `/smbackend` as the user decided, and confirm in the plan's checkpoint.
2. **How do admins delete chat messages and forum posts (D-17)?**
- What we know: no chat-family plugin has admin screens.
- Options:
- (a) A demo moderator user group. `DemoContext.CanModerateMessage` (delete in group conversations) and the forum resolver's `CanManage` grant deletion. bm's `DeleteDialog` already supports moderators, and admins add users to the group in the user admin.
- (b) cabana list screens in `golem15.demo` or `sm-moderation-plugin` over chat messages and forum posts, with delete actions. That puts chat/forum coupling into moderation, or app code into demo.
- Recommendation: (a) now, and (b) as a backlog item.
3. **Anonymous `/forum`.** D-14 does not say whether anonymous visitors see the forum, and the forum API is JWT-only. Recommendation: use the D-04/D-18 pattern (description plus "register to read and post") unless the user wants a public read endpoint in `golem15.demo`.
4. **D-02 scope vs. bm reality.** bm has no threads, pins, multi-channel list or link embeds. Recommendation:
- Threads: replies through `/messages/{id}/replies`, plus `PUT /threads/{rootId}/mute`.
- Pins: chatchannels pins API.
- Link embeds: linkified URLs. No unfurl backend exists, so no preview card. Add a slot or extension point for host embed renderers (bm's entity embeds become a host extension).
Confirm with the user that link embeds without previews is enough.
5. **New git.golem15.com repos.** `vue-chat-layer`, `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` and `sm-moderation-plugin` must be created on git.golem15.com (as for vue-oauth-layer in 32.1). The layers must be public or pnpm-installable from the build machine (existing layers install via `git+https`). The plan should start with a user-run or token-backed repo creation step.
6. **Firewall change on a shared production host.** Opening the LiveKit ports through Vuurmuur needs the user's go-ahead. Plan it as a `checkpoint:human-action`.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | all Go repos | ✓ | go1.27.2 | — |
| Docker | testcontainers tests | ✓ | 29.9.1 | `-short` tests |
| Node | layers, vue-demo-app | ✓ | v24.22.0 local (rome site runs Node 22.22.0) | — |
| pnpm | layers | ✓ | 12.14.0 | — |
| livekit-server (local) | dev.sh video, tests | ✓ | 1.13.1 | `classes.FakeRooms` in Go tests |
| livekit-server (rome) | D-10 | ✓ binary present | 1.13.1 | — |
| centrifugo (local + rome) | realtime | ✓ | v6.5.1 | null driver |
| rome free ports 7890-7892 | demo LiveKit | ✓ (not listening today) | — | other free ports |
| rome firewall opening | media | ✗ (Vuurmuur rule needed) | — | none for media; blocks calls |
| ssh rome | deploy | ✓ (`debian@10.8.3.118 -p 2222`, passwordless sudo) | — | — |
**Missing dependencies with no fallback:** the Vuurmuur rule for the demo LiveKit media ports (user-approved action).
**Missing with fallback:** none.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework (Go) | `go test` (testcontainers Postgres for full runs; `-short` skips Docker) |
| Framework (layers) | `node --test` (existing layers); **vitest 3.2.7** recommended for `vue-chat-layer` (bm's chat tests are vitest, `vi.mock`) |
| Framework (app site) | vitest 3.2.7 (`sm-demo-app/vue-demo-app/vitest.config.ts`) |
| Quick run (plugin) | `go vet ./... && go test -short ./...` in the plugin checkout |
| Quick run (demo) | `cd ../sm-demo-app && go vet ./... && go test -short ./...` |
| Full suite (demo) | `scripts/check-all.sh` (Go app + summercms_mcp tag, user/journal plugin tests, vitest, sync-roadmap, check-pins, build release, smoke, check-deploy, browser checks, check-live) |
### Phase Requirements → Test Map
| Req | Behavior | Test Type | Automated Command | File Exists? |
|-----|----------|-----------|-------------------|-------------|
| SC-1 | go.mod requires tags for all chat-family modules and summer.yaml lists them | unit | `go test -run TestGoModRequiresTags .` (sm-demo-app) | ✅ (extend wantPlugins) |
| SC-1 | checkouts on required tags | script | `scripts/check-pins.sh` | ✅ |
| SC-1 | every plugin activates, routes mounted (`/_chat/api/v1`, `/_chatchannels/api/v1`, `/_chatvideo/api/v1`, `/_chatbots/api/v1`, `/_forum/api/v1`, `/_userfriends/api/v1`, `/_herald/api/v1`, `/_demo/api/v1/chat`) | unit | `go test -short -run TestBootAllPlugins .` | ✅ extend |
| SC-1 | release builds without go.work | script | `scripts/build.sh release` | ✅ |
| SC-1 | demo:seed fills chat/channels/threads/reactions/pins/forum/friends EN+PL, idempotent | integration (Docker) | `go test -run 'TestSeed' .` | ❌ Wave 0: `seed_chat_test.go` |
| SC-1 | reset wipes chat family and reseeds | integration | `go test -run TestResetKeepsPlans .` | ✅ extend |
| D-06 | demo context membership, live membership, banned user excluded; channel adapter resolves | unit/integration | `go test -run 'TestDemoChat' ./plugins/demo/...` | ❌ Wave 0 |
| D-07 | members cannot create channels (403/404 on POST channels) | integration | `go test -run TestDemoChannelsSeededOnly .` | ❌ |
| D-11 | `video/start` on a channel conversation → 422; on DM → 201 with FakeRooms | integration | `go test -run TestDemoCallsDMOnly .` | ❌ |
| Pitfall 4 | `/api/realtime/token` 200 for any member, 401 without JWT | unit | `go test -run TestRealtimeRoutes .` | ✅ extend |
| D-18 | seam: no listener → identical behaviour; rejecting listener → 422 errors.content / errors.body, nothing persisted, no event | unit + Docker | `go test -short ./...` + `go test -run 'TestMessageSaving|TestPostSaving' .` in sm-chat-plugin / sm-forum-plugin | ❌ |
| D-18 | PHP parity untouched | unit | existing `php_method_map_test.go`, `golden_test.go` | ✅ |
| D-17 | filter: words (case-insensitive, word boundary), links (scheme/host/bare domain), settings round-trip | unit | `go test ./...` in sm-moderation-plugin | ❌ new repo |
| D-05 | demo commands listed and dispatched ephemeral | unit | `go test -run TestDemoCommands ./plugins/demo/...` | ❌ |
| Centrifugo | namespaces chat/user/video/presentations valid | script | `scripts/check-deploy.sh` (`centrifugo checkconfig`) | ✅ extend |
| LiveKit | config valid, ports agree across supervisor/nginx/config | script | `scripts/check-deploy.sh` | ❌ extend |
| D-01/02 | store: channels, DMs, threads, unread, mentions, reactions, typing, events parsing | unit | `pnpm test` in vue-chat-layer (ported `chat.test.ts`, `chatEvents/Format/Text/Unread.test.ts`) | ❌ port |
| D-13 | call page state machine (join/leave/mute/camera, token errors) with a mocked Room | unit | `pnpm test` in vue-chatvideo-layer | ❌ |
| D-14/15 | forum and friends API clients (URL building, error envelopes) | unit | `pnpm test` in each layer | ❌ |
| SC-2/3 | live: /chat prompt anonymous, /forum, CTA, /backend 404, /_chat 401 without JWT, `/livekit/rtc/validate` reachable, /ws 101 | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend |
| SC-2 | two-browser chat realtime + DM call | manual (browser) | `scripts/check-local.sh chat` (Playwright, as check-remote.cjs) + human UAT on live | ❌ |
### Sampling Rate
- **Per task commit:** the quick run of the repo touched (`go test -short ./...` or `pnpm test`)
- **Per wave merge:** sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
- **Phase gate:** `scripts/check-all.sh` green, then `/msd-verify-work`
### Wave 0 Gaps
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, adapter, resolvers, DM-only calls, bootstrap JSON)
- [ ] `sm-chat-plugin` seam tests; `sm-forum-plugin` seam tests
- [ ] `sm-moderation-plugin` package + tests (new repo)
- [ ] `vue-chat-layer` vitest config + ported bm tests (rewrite `setActivePinia(createPinia())` setup for the composable store)
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`
- [ ] `scripts/dev.sh`: start a local `livekit-server` (dev keys, ports 7890-7892 or `--dev`) like Centrifugo; `scripts/check-local.sh chat`
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | `jwt.auth` on every chat-family route; Turnstile on register (existing) |
| V3 Session Management | yes | user JWT + refresh; realtime tokens (lighthouse, TTL 3600); LiveKit join tokens (HS256, `token_ttl` 900) |
| V4 Access Control | yes | Host resolvers (demo context membership, live membership), chat participant checks, IDOR→404 in plugins, DM-only calls via `max_participants: 2` |
| V5 Input Validation | yes | Plugin validators + new pre-save moderation seam; forum Markdown sanitized on write (`breakwater.Render`); chat content rendered as text (bm `chatText`), never `v-html` of raw content |
| V6 Cryptography | yes | No hand-rolled crypto: LiveKit keys and VAPID keys generated (`openssl rand`, `herald:vapid-generate`), stored in `.env` 0640 |
| V12 Files | yes | Chat attachments private, served only to participants; upload body limit 1 MiB default (keep, nginx `client_max_body_size 2m`) |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Spam/abuse in public chat and forum | Tampering / DoS | Moderation filter (pre-save), plugin buckets (`chat-api` 120/min, `chat-dm` 10/min, `chat-upload` 20/min, `forum-api`, `video-initiate` 5/min), Turnstile, reset job, user ban |
| Stored XSS via messages/posts/names | Tampering | Text rendering for chat; server-sanitized `content_html` only for forum; no `v-html` on user fields |
| Subscribing to others' conversations | Information disclosure | Centrifugo subscribe proxy → `AuthorizeChat` (participants only), `AuthorizeVideo`, user authorizer (`user:{self}`) |
| LiveKit key co-tenancy with WavePath | Elevation | Separate LiveKit instance and keys (not a second key on WavePath's server) |
| Origin IP disclosure via ICE | Information disclosure | Accepted (rome already exposes services on its IP); document in DEPLOY.md |
| Call abuse (long or large rooms) | DoS | `max_duration` 900 + reconcile, `room.max_participants` 4, `empty_timeout`, `video-initiate` bucket |
| Upload abuse | DoS | chat pending quota (10 files), prune job hourly, 1 MiB body limit |
| Secrets in logs/payloads | Information disclosure | Same discipline as 19.4.1: keys only by name; never in `NUXT_PUBLIC_*` except public ones (VAPID public key) |
## Sources
### Primary (HIGH confidence)
- Repo reads this session:
- `sm-bm-app/plugins/golem15/{chat,chatchannels,chatbots}` (README, classes, api, boot, events, fixture tests)
- `sm-{chatvideo,forum,herald,userfriends}-plugin` (README, config, classes, api)
- `sm-bm-app/plugins/jz/bm/{chat.go,classes/chat_context.go,classes/chat_sync.go,controllers/api/chat.go,realtime_access.go}`
- `sm-bm-app/frontend/src/{api/chat.ts,api/types.ts,stores/chat.ts,lib/chatEvents.ts,components/chat/*}`
- `sm-demo-app/{summer.yaml,go.mod,go.work,.gitmodules,DEPLOY.md,README.md,scripts/check-pins.sh,go_mod_test.go,boot_test.go,plugins/demo/*,deploy/*,config/*}`
- `vue-{faq,paymentgateway,oauth}-layer`
- `sm-demo-app/vue-demo-app`
- `summercms.go/modules/{festival,lighthouse,fetchguard,party,conga}` README and source
- Scratch compile and tests of all seven plugins against framework v0.5.1 (go build/vet/test -short)
- `git ls-remote` per plugin repo; npm registry (`npm view`); msd package-legitimacy seam
- Read-only ssh probe of rome (ports, supervisor programs, LiveKit config with keys redacted, Vuurmuur service, iptables)
### Secondary (MEDIUM confidence)
- LiveKit config sample: https://raw.githubusercontent.com/livekit/livekit/master/config-sample.yaml
- livekit-client URL handling: https://raw.githubusercontent.com/livekit/client-sdk-js/main/src/api/utils.ts
### Tertiary (LOW confidence)
- [upd.dev patch notes on /rtc/v1 fallback](https://upd.dev/slopus/happy/compare/4533ef5666..c97ea347f1.patch)
- [LiveKit community: v1.13.1 signalling drops behind nginx ssl_preread](https://community.livekit.io/t/livekit-v1-13-1-signaling-ws-drops-mid-call-with-client-request-leave-full-reconnect-media-udp-unaffected-behind-nginx-ssl-preread/1481)
- [client-sdk-js PR #1785 (new rtc path)](https://app.semanticdiff.com/gh/livekit/client-sdk-js/pull/1785/overview)
- [livekit-client 2.18.4 release notes](https://newreleases.io/project/npm/livekit-client/release/2.18.4)
- [LiveKit firewall tips](https://kb.livekit.io/articles/1724892785-establishing-media-connection-firewall-troubleshooting)
## Suggested Plan Split (for the lean-planning confirmation)
| # | Scope (one line) | Repos |
|---|------------------|-------|
| 01 | Shared plugin release: fast-forward siblings; fix chatvideo/herald/userfriends go.mod; chat v0.1.4 and forum pre-save seams; new `sm-moderation-plugin`; tag all eight (with go-ahead) | 8 plugin repos |
| 02 | Demo backend: oauthserver bump (folded todo); mount 8 plugins (submodules, go.work, go.mod, summer.yaml); demo context + channel adapter + resolvers; bootstrap endpoints; realtime guard; chatbots commands; moderation wiring; seed + reset; Centrifugo namespaces; local LiveKit in dev.sh | sm-demo-app |
| 03 | `vue-chat-layer` v0.1.0 (port, multi-channel, threads, pins, command picker, shared realtime) and its use in vue-demo-app (`/chat`, floating panel, Chat tour card, EN/PL) | vue-chat-layer, vue-demo-app |
| 04 | `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` v0.1.0 and their pages and tour cards in vue-demo-app; herald VAPID config | 3 layers, vue-demo-app, sm-demo-app |
| 05 | rome deploy: LiveKit program, Vuurmuur rule (checkpoint), nginx locations, `.env` keys (LiveKit, VAPID), Centrifugo restart, release, DEPLOY.md, check-deploy and check-live extensions | sm-demo-app, rome |
| 06 | Unit tests (Go, layers, site) and live verification incl. a two-browser chat and DM call | all |
Plans 03 and 04 can run in parallel after 02. 05 can merge into 04 if the user prefers fewer plans.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH. Versions come from go.mod files and the registry, and compatibility was compiled this session.
- Architecture: HIGH for the backend glue (code read), MEDIUM for the layer design (new multi-channel work).
- LiveKit deployment: MEDIUM. rome was probed, but the client path behaviour on server 1.13.1 behind Cloudflare is assumed.
- Pitfalls: HIGH for repo, realtime and seed issues, MEDIUM for the network ones.
**Research date:** 2026-10-28
**Valid until:** 2026-11-27 for the repo state (re-check `git ls-remote` before tagging); 7 days for the livekit-client version pick

View File

@@ -0,0 +1,31 @@
---
phase: 20-demo-instance-with-every-shared-plugin
review: 20-REVIEW.md
recorded: 2026-11-02
closed: 2026-11-03
---
# Phase 20 review disposition
One row per finding in 20-REVIEW.md. `fixed` names the commits and the release tag and the check that confirmed it on the live demo (release 195aa213c546, deployed 2026-11-02 by plan 20-09); `deferred` points at the todo that tracks it. No row is open.
| Finding | Severity | Disposition | Note |
|---|---|---|---|
| CR-01 | critical | fixed | Leaving or unmounting during start/join still connects and publishes camera and microphone. vue-chatvideo-layer 69e8b83, 27ad81c (v0.1.1): a per-attempt token with stale checks after every await; leave is final at any moment. Live: `check-chat.cjs --call` ok against demo.summercms.io. |
| CR-02 | critical | fixed | Banned-link filter bypass through URL forms the browser normalises, rendered as working links. sm-moderation-plugin 266fc60, 9715ce7 (v0.1.1): link hosts and paths canonicalised like the URL parser; vue-chat-layer a798bb1 (v0.1.1): deceptive hosts render as text; sm-demo-app 262da37 (CheckHTML on the rendered forum post). Live: `check-moderation.sh` ok (fixed probe, sm-demo-app da481c9), every link form refused in chat, forum reply and thread title. |
| CR-03 | critical | fixed | Banned-word filter bypass through invisible characters, Unicode spaces and forum Markdown. sm-moderation-plugin 266fc60, 9715ce7, 334697b (v0.1.1): words matched after NFKC, format characters, Unicode spaces, diacritics, entities and inline markup; MatchHTML/CheckHTML; sm-forum-plugin ec1a82c (v0.1.2): PostSaving.HTML; sm-demo-app 262da37 (listener checks the rendered post). Live: `check-moderation.sh` ok, every word form refused. |
| WR-01 | warning | fixed | Logout leaves the chat store and the realtime socket of the previous user running. vue-demo-app 188ae25 (pinned in sm-demo-app 27c48cf with layers v0.1.1): logout and unauthorized reset the chat, close the realtime socket and dismiss the ring. Local: check-chat.cjs default mode, every /ws socket closed within 5 s of Log out (not run live: it posts in #general). |
| WR-02 | warning | deferred | Any member can pin or unpin any message in every public channel; the message is not checked against the channel. Todo: .planning/todos/pending/chatchannels-pin-policy.md |
| WR-03 | warning | deferred | Member-visible text outside the chat and forum seams is never moderated. Todo: .planning/todos/pending/moderation-profile-group-and-filenames.md |
| WR-04 | warning | fixed | A link opens a call and turns the camera and microphone on with no confirmation. vue-chatvideo-layer 5386903 (v0.1.1): the call page asks before media unless the call button or Accept handed over; vue-demo-app 9a12701 (pins). Live: `check-chat.cjs --call`, a direct /call load showed the confirmation with no LiveKit socket, tile or session before the click. |
| WR-05 | warning | fixed | A failed LiveKit connect posts `leave`, not `end`, leaving an empty ringing session behind. vue-chatvideo-layer 27ad81c (v0.1.1): a failed connect (or livekit-client import) ends a session nobody else joined, from GET status. Unit tests in useCall.test.ts; deployed with release 195aa213c546. |
| WR-06 | warning | deferred | The forum and friends clients have no 401 refresh; an expired token shows "not found". Todo: .planning/todos/pending/forum-friends-client-401-refresh.md |
| IN-01 | info | deferred | Group calls in public channels are blocked only by the seeded participant count. Todo: .planning/todos/pending/chatvideo-dm-only-host-hook.md |
| IN-02 | info | deferred | chatvideo checks participant rows, not live membership. Todo: .planning/todos/pending/chatvideo-live-membership.md |
| IN-03 | info | deferred | check-local passes member JWTs to curl in argv. Todo: .planning/todos/pending/check-local-jwt-out-of-argv.md |
| IN-04 | info | deferred | A chat message that starts with "/" can never be posted. Todo: .planning/todos/pending/chat-slash-text-escape.md |
| IN-05 | info | deferred | `demo:seed` brings back the default moderation lists after an admin empties them. Todo: .planning/todos/pending/demo-seed-moderation-lists-once.md |
| IN-06 | info | deferred | nginx `/livekit/` exposes LiveKit's whole HTTP surface. Todo: .planning/todos/pending/demo-nginx-livekit-rtc-only.md |
| IN-07 | info | deferred | The incoming-call runtime can leak a session handle and survives logout. The logout half was fixed by 20-08 (vue-demo-app 188ae25); the handle leak remains. Todo: .planning/todos/pending/chatvideo-incoming-handle-leak.md |
| IN-08 | info | deferred | The VAPID runbook pipeline runs without `pipefail`. Todo: .planning/todos/pending/demo-vapid-runbook-pipefail.md |
| IN-09 | info | deferred | `GET /chat/members` writes, and builds an unbounded `IN` list. Todo: .planning/todos/pending/demo-chat-members-read-only.md |

View File

@@ -0,0 +1,387 @@
---
phase: 20-demo-instance-with-every-shared-plugin
reviewed: 2026-11-01T23:50:42Z
depth: standard
files_reviewed: 105
files_reviewed_list:
- ../sm-chat-plugin/api/errors.go
- ../sm-chat-plugin/classes/service.go
- ../sm-chat-plugin/classes/writes.go
- ../sm-chat-plugin/contracts/rejection.go
- ../sm-chat-plugin/events/events.go
- ../sm-chatvideo-plugin/go.mod
- ../sm-demo-app/config/env/production/golem15.chatvideo.yaml
- ../sm-demo-app/config/golem15.chatvideo.yaml
- ../sm-demo-app/config/golem15.chat.yaml
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/deploy/supervisor/summercms-demo-livekit.conf
- ../sm-demo-app/deploy/centrifugo.json.example
- ../sm-demo-app/deploy/livekit.yaml.example
- ../sm-demo-app/deploy/env.example
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/.gitmodules
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.work
- ../sm-demo-app/plugins/demo/chat_api.go
- ../sm-demo-app/plugins/demo/chatbots.go
- ../sm-demo-app/plugins/demo/chat.go
- ../sm-demo-app/plugins/demo/classes/chat_context.go
- ../sm-demo-app/plugins/demo/classes/forum_access.go
- ../sm-demo-app/plugins/demo/config/config.yaml
- ../sm-demo-app/plugins/demo/console/reset.go
- ../sm-demo-app/plugins/demo/console/seed_chat.go
- ../sm-demo-app/plugins/demo/console/seed_forum.go
- ../sm-demo-app/plugins/demo/console/seed_friends.go
- ../sm-demo-app/plugins/demo/console/seed.go
- ../sm-demo-app/plugins/demo/moderation.go
- ../sm-demo-app/plugins/demo/plugin.go
- ../sm-demo-app/plugins/demo/realtime_access.go
- ../sm-demo-app/plugins/demo/routes.go
- ../sm-demo-app/plugins.gen.go
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/scripts/check-chat.cjs
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/scripts/check-live.sh
- ../sm-demo-app/scripts/check-local.sh
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/scripts/smoke.sh
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/vue-demo-app/app/assets/css/site.css
- ../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue
- ../sm-demo-app/vue-demo-app/app/components/SiteNav.vue
- ../sm-demo-app/vue-demo-app/app/components/TourCard.vue
- ../sm-demo-app/vue-demo-app/app/layouts/default.vue
- ../sm-demo-app/vue-demo-app/app/lib/realtime.ts
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
- ../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue
- ../sm-demo-app/vue-demo-app/app/pages/chat.vue
- ../sm-demo-app/vue-demo-app/app/pages/forum/index.vue
- ../sm-demo-app/vue-demo-app/app/pages/forum/t/[id].vue
- ../sm-demo-app/vue-demo-app/app/pages/friends.vue
- ../sm-demo-app/vue-demo-app/app/pages/index.vue
- ../sm-demo-app/vue-demo-app/app/plugins/auth.ts
- ../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/vue-demo-app/pnpm-lock.yaml
- ../sm-demo-app/vue-demo-app/vitest.config.ts
- ../sm-forum-plugin/api/errors.go
- ../sm-forum-plugin/classes/forum_service.go
- ../sm-forum-plugin/classes/lifecycle.go
- ../sm-forum-plugin/contracts/rejection.go
- ../sm-forum-plugin/events/events.go
- ../sm-herald-plugin/go.mod
- ../sm-moderation-plugin/admin.go
- ../sm-moderation-plugin/classes/filter.go
- ../sm-moderation-plugin/go.mod
- ../sm-moderation-plugin/models/settings.go
- ../sm-moderation-plugin/plugin.go
- ../sm-moderation-plugin/config/config.yaml
- ../sm-moderation-plugin/models/settings/fields.yaml
- ../sm-moderation-plugin/updates/202610280001_create_golem15_moderation_settings.go
- ../sm-userfriends-plugin/go.mod
- ../vue-chat-layer/app/assets/css/chat-tokens.css
- ../vue-chat-layer/app/components/Chat/CommandPicker.vue
- ../vue-chat-layer/app/components/Chat/FloatingPanel.vue
- ../vue-chat-layer/app/components/Chat/Header.vue
- ../vue-chat-layer/app/components/Chat/ThreadPane.vue
- ../vue-chat-layer/app/components/Chat/Message.vue
- ../vue-chat-layer/app/components/Chat/MessageList.vue
- ../vue-chat-layer/app/components/Chat/LinkEmbed.vue
- ../vue-chat-layer/app/composables/useChat.ts
- ../vue-chat-layer/app/lib/client.ts
- ../vue-chat-layer/app/lib/chatText.ts
- ../vue-chat-layer/app/lib/markdown.ts
- ../vue-chat-layer/app/lib/linkEmbeds.ts
- ../vue-chat-layer/app/lib/realtime.ts
- ../vue-chat-layer/app/lib/slots.ts
- ../vue-chat-layer/nuxt.config.ts
- ../vue-chat-layer/vitest.config.ts
- ../vue-chatvideo-layer/app/composables/useCall.ts
- ../vue-chatvideo-layer/app/composables/useIncomingCalls.ts
- ../vue-chatvideo-layer/app/lib/callMachine.ts
- ../vue-chatvideo-layer/app/lib/videoClient.ts
- ../vue-chatvideo-layer/app/components/Chatvideo/Call.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/CallButton.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/IncomingCall.vue
- ../vue-chatvideo-layer/app/components/Chatvideo/Tile.vue
- ../vue-chatvideo-layer/nuxt.config.ts
- ../vue-forum-layer/app/components/Forum/Thread.vue
- ../vue-forum-layer/app/utils/forumClient.ts
- ../vue-userfriends-layer/app/components/Friends/Groups.vue
- ../vue-userfriends-layer/app/utils/friendsClient.ts
findings:
critical: 3
warning: 6
info: 9
total: 18
status: issues_found
---
# Phase 20: Code Review Report
**Reviewed:** 2026-11-01T23:50:42Z
**Depth:** standard
**Files Reviewed:** 105 (the 86 listed files, plus the layer components, libs and deploy files the SUMMARYs under-listed)
**Status:** issues_found
## Summary
The review covered the phase's code in its sibling repositories. That is the chat and forum pre-save seams (`git diff v0.1.4..main` and `v0.1.0..main`; both tags exist), the new sm-moderation-plugin, the sm-demo-app host plugin, deploy files and scripts (commits since 2026-10-28), vue-demo-app since 44a5229, and the four new layers.
Areas that held up when traced:
- **Pre-save seams.** Both are additive and silent without a listener, and they map `contracts.Rejection` to 422 on every chat and forum write path (send, reply, ReplyTo, edit; thread, reply, edit).
- **Core plugins.** The phase changed no core plugin (user, journal/blog, pages, payment). sm-user-plugin is consumed by tag and unchanged.
- **XSS.** `renderChatText` escapes everything before it adds tags, placeholders cannot be forged (`\u0000` is stripped), and `safeUrl` refuses `javascript:`, `data:` and `//` / `/\` paths. Forum `content_html` comes from goldmark plus bluemonday. Attachments download as octet-stream with the `download` attribute. No XSS was found.
- **Realtime authorization.** Every Centrifugo namespace goes through the subscribe proxy. The `user`, `chat`, `video` and `presentations` authorizers each check their own rule, so giving every member a connection token does not widen channel access. LiveKit tokens are scoped to one room, with no `roomAdmin`.
- **Runbook secrets.** The runbooks keep secrets out of argv and logs.
Three defects need fixing before more traffic reaches the demo:
1. **Call race (CR-01).** The call composable connects to LiveKit and publishes the camera and microphone after the user has already left or navigated away.
2. **Link filter bypass (CR-02).** The banned-link filter misses URL forms that browsers normalise to the banned host. The chat then renders them as working links and link cards.
3. **Word filter bypass (CR-03).** The banned-word filter misses invisible characters, non-breaking spaces and forum Markdown that renders to the banned word.
Each bypass was confirmed by running `classes.Match` against the probes; all of them returned `flagged=false`.
## Critical Issues
### CR-01: Leaving or unmounting during start/join still connects and publishes camera and microphone
**File:** `../vue-chatvideo-layer/app/composables/useCall.ts:177-231` (also `258-266`, `274-276`; Leave is enabled while `starting`/`connecting` in `../vue-chatvideo-layer/app/components/Chatvideo/Call.vue:72,91`)
**Issue:** `begin()` never re-checks the state or `leaving` after its awaits (`client.start`/`client.join` at line 184, `import('livekit-client')` at line 193). It also assigns `room` only after the import. `leave()` sees `room === null`, so it cannot disconnect anything.
- **Caller:** Leave is clicked while the status shows "Connecting…" (the state is `starting`). `leave()` dispatches `ended`, posts `end` (404, because the session does not exist yet) and navigates away. Then `start` resolves. `begin` subscribes `video:session:{id}`, loads livekit-client, creates a `Room`, connects, and runs `setCameraEnabled(true)` and `setMicrophoneEnabled(true)`. The server session was created and the callee is ringing. If they accept, they see and hear a caller who believes they hung up. Nothing on the page can stop it, and the channel handle leaks.
- **Callee:** this path is worse. `begin(false)` dispatches nothing before `await client.join()`, so the state stays `idle`. Navigating away during the join runs `onBeforeUnmount → leave()`, which returns early on `status === 'idle'` (line 259). Then the join resolves: `transition` lets `joined` move `idle → connecting`, and the callee is live in the room with camera and microphone, with no UI and no leave posted.
- **Second join:** the same missing guard lets a second `begin(false)` start while the first is pending. That creates two Rooms, and the first is never disconnected.
**Fix:** Add a per-attempt token and bail out after every await. Mark the attempt live before the first await. Make `leave()` work from the pre-room states.
```ts
let attempt = 0
async function begin(start: boolean) {
if (state.value.status !== 'idle' && isLive(state.value)) return
const mine = ++attempt
leaving = false
dispatch({ type: 'start' }) // also for the callee: leaves 'idle' before any await
const stale = () => mine !== attempt || leaving || !isLive(state.value)
let join: VideoJoin
try { join = start ? (takeStart(conversationId) ?? (await client.start(conversationId))) : await client.join(conversationId) }
catch (e) { if (!stale()) dispatch({ type: 'error', code: e instanceof VideoApiError ? e.status : 0, message: e instanceof Error ? e.message : '' }); return }
if (stale()) { void (start ? client.end(conversationId) : client.leave(conversationId)).catch(() => {}); return }
// ...same checks after import('livekit-client'), after r.connect(), and before each setXEnabled:
// if (stale()) { await r.disconnect(true); return }
}
async function leave() {
if (leaving || !isLive(state.value)) return // no early return on 'idle' while an attempt is pending
leaving = true; attempt++ // invalidates the in-flight begin
// ...
}
```
Add `useCall.test.ts` cases: "leave while start is pending" and "unmount while join is pending". Both must assert that `Room.connect` and `setCameraEnabled` are never called.
### CR-02: Banned-link filter bypass through URL forms the browser normalises, rendered as working links
**File:** `../sm-moderation-plugin/classes/filter.go:50,122-135`; `../sm-moderation-plugin/models/settings.go:137-168`; rendering in `../vue-chat-layer/app/lib/chatText.ts:87-93`, `../vue-chat-layer/app/lib/linkEmbeds.ts:40-47,69-83` and `../vue-chat-layer/app/components/Chat/LinkEmbed.vue:13`
**Issue:** `hostPattern` matches the literal text and compares hosts and paths byte for byte. The WHATWG URL parser (browsers, and `new URL` in `linkEmbeds.ts`) percent-decodes and IDNA-maps the host, so each form below reaches the banned host. Every probe returned `flagged=false` against `Match(["viagra",...], ["bit.ly","example.com/promo"], …)`. With `node`, `new URL()` resolves each one to `bit.ly`:
| Text sent | What the browser opens |
|---|---|
| `https://bit%2Ely/x` | `https://bit.ly/x` |
| `https://bit.l%79/x` | `https://bit.ly/x` |
| `https://bit.ly/x` (fullwidth) | `https://bit.ly/x` |
| `https://bit。ly/x` (U+3002) | `https://bit.ly/x` |
| `https://example.com:443/promo` | path rule skipped: the port stops the path group |
| `https://example.com/%70romo`, `//promo`, `https://example.com./promo` | path-prefix rules skipped |
| `https://sub.example.com/promo` | path rules never cover subdomains |
| punycode `xn--…` vs. Unicode host | either spelling of an IDN bypasses the other |
The chat then renders the bypass as a clickable `<a href="https://bit%2Ely/x">`. `ChatLinkEmbed` makes it worse: its card shows the normalised host `bit.ly` and links to `https://bit.ly/x`. The forum renders the same through goldmark autolinks. Spammers can post any banned link, which defeats D-17 for links.
**Fix:** Normalise both sides the way the browser does before comparing. In the filter, find every candidate URL or host, then:
```go
// for each candidate: s := found[0]; if no scheme, s = "http://" + s
u, err := url.Parse(s) // then:
host, _ := url.PathUnescape(u.Hostname()) // %2E -> .
host = strings.Map(func(r rune) rune { // ideographic/fullwidth dots
switch r { case '。', '.', '。': return '.' }; return r }, host)
host, err = idna.Lookup.ToASCII(norm.NFKC.String(host)) // fullwidth -> ASCII, Unicode -> punycode (x/net/idna)
host = strings.TrimSuffix(strings.TrimPrefix(strings.ToLower(host), "www."), ".")
path := path.Clean("/" + mustUnescape(u.EscapedPath())) // //promo, /./promo, %70romo
```
Apply the same `idna.Lookup.ToASCII` and NFKC to entries in `LinkEntry`. Let path rules also match subdomains, as host rules do. Widen `hostPattern` so its host class takes `%`, `.` and `。`, or tokenise on whitespace and parse each token. Add these probes to `filter_test.go`. `golang.org/x/net/idna` and `x/text` are already transitive dependencies of the framework; note them per the dependency rule.
### CR-03: Banned-word filter bypass through invisible characters, Unicode spaces and forum Markdown
**File:** `../sm-moderation-plugin/classes/filter.go:45,91,94,115`; seams `../sm-forum-plugin/classes/forum_service.go:284,341` and `../sm-forum-plugin/classes/lifecycle.go:235`; listener `../sm-demo-app/plugins/demo/moderation.go:65,86`
**Issue:** The word regexp uses `[^\p{L}\p{N}_]` as its boundary and RE2's ASCII-only `\s+` between the words of a phrase. Confirmed with `classes.Match`:
- **Invisible characters.** `via​gra` (zero-width space) and `vi­agra` (soft hyphen) display as "viagra" and are not flagged. Format characters (`\p{Cf}`) count as boundaries.
- **Unicode spaces.** `buy followers` (NBSP; likewise U+2009, U+3000) is not flagged.
- **NFD text.** Polish diacritics typed in decomposed (NFD) form do not match an NFC entry, and the combining mark counts as a boundary.
- **Forum Markdown.** The forum seam passes the raw Markdown body and title, but members see `breakwater.Render` output. `via**gra**` renders "via**gra**" as one word, `vi&#97;gra` renders "viagra", and `[x](https://bit&#46;ly/a)` renders a link to `bit.ly` because CommonMark decodes entities in link destinations. None of them are flagged.
Moderation is therefore cosmetic against anyone who tries once.
**Fix:** Normalise the text before matching, in `Filter.Check` (and `Match`):
```go
t := norm.NFKC.String(text)
t = strings.Map(func(r rune) rune {
if unicode.Is(unicode.Cf, r) { return -1 } // ZWSP, ZWJ, soft hyphen, BOM...
if unicode.IsSpace(r) { return ' ' } // NBSP, thin, ideographic spaces
return r
}, t)
```
Use `[\p{Z}\s]+` in place of `\s+` between phrase words. Normalise entries with the same function in `ParseList`.
For the forum, check the rendered text too. In the demo's `checkPost`, run the filter on `html.UnescapeString` of `breakwater.Render(body)` with tags stripped, and on the hrefs it contains. Alternatively, have forum's `PostSaving` carry the rendered HTML (an additive field) so every host gets it.
## Warnings
### WR-01: Logout leaves the chat store and the realtime socket of the previous user running
**File:** `../sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts:83-92`, `../sm-demo-app/vue-demo-app/app/components/SiteHeader.vue:12-15`, `../sm-demo-app/vue-demo-app/app/utils/communityAuth.ts:57`; `reset()` in `../vue-chat-layer/app/composables/useChat.ts:1514-1516`
**Issue:** `logout()` blacklists the JWT and clears the cookie. Nothing calls `useChat().reset()` or the realtime `disconnect()`, and the floating panel just unmounts. `start()` is the only caller of `reset()`, and it runs only when a chat view mounts again. After logout the tab therefore keeps:
- the Centrifugo connection and the `user:{me}` subscription, which deliver the old user's DM and call events until the connection token expires;
- the old user's messages and DMs in `useState`;
- a pending incoming-call prompt in `chatvideo:incoming`.
`$chatAuth.unauthorized()` has the same gap: it only clears the cookie. The layer's own comment says reset exists so that a second account never sees the first one's chat, but the host never wires it.
**Fix:** In `logout()` (and in `unauthorized`), after clearing the token, call `useChat().reset()` (it disconnects the shared client) and clear `useState('chatvideo:incoming')`. Alternatively, `watch(loggedIn)` in `plugins/auth.ts` and reset when it turns false. Add a vitest case for the logout path.
### WR-02: Any member can pin or unpin any message in every public channel; the message is not checked against the channel
**File:** `../sm-demo-app/plugins/demo/classes/chat_context.go:259-269` (the policy) reaching `../sm-chatchannels-plugin/classes/channel_service.go:1332-1353` and `../sm-chatchannels-plugin/api/pins.go:31-52`
**Issue:** chatchannels gates `PinMessage` and `UnpinMessage` only with `requireChannelAccess`. The demo's `ChatAccess.CanAccessContext` answers true for every member, so any registered visitor can unpin the seeded welcome pins in #general, #announcements and #ogólny, or flood the pins panel. `PinMessage` also never checks that `messageID` belongs to the channel's conversation, so pins can point at another conversation's (DM) message ids. Contents do not leak, because the client's `GET /messages/{id}` needs participation, but the rows are bogus. Plan 03 noted "the visitor was allowed to pin … the pin succeeded" without treating it as a gap.
**Fix:** Make pinning a manage action, for example additively in chatchannels: `PinMessage`/`UnpinMessage` require `CanManageContext`, or an optional `contracts.PinPolicy` the host implements. The demo then answers it from the moderator group. Also verify `message.conversation_id == *ch.ConversationID` before creating the pin. Until then, the demo layer can hide pin actions for non-moderators (`pinAllowed`), but the server must enforce the rule.
### WR-03: Member-visible text outside the chat and forum seams is never moderated
**File:** `../sm-demo-app/plugins/demo/moderation.go:28-44`
**Issue:** The filter runs only on `MessageSaving.Content` and the `PostSaving` title and body. Members write other text that other members see:
- **Profile name and surname.** They appear in `/chat/members` for every member, in mention chips, in DM headers, as forum `author_name`, and as `caller_name` in the incoming-call prompt.
- **userfriends group names and descriptions.** They are shown to invitees in the code preview.
- **Chat attachment filenames.**
A member registering as "free crypto bit.ly/x" puts that text in front of everyone, including in a ringing prompt. D-17 states that banned words and links are refused on every write path.
**Fix:** Run `Filter.Check` on the profile name and surname at registration and profile update. Use a user-plugin saving event if one exists, otherwise the demo's own registration route, without changing the core user plugin. Do the same for userfriends group create and update if a seam exists, or add an additive `GroupSaving` seam, and for attachment filenames (chat `MessageSaving` could carry them additively). Record any path left out as an explicit, documented exception.
### WR-04: A link opens a call and turns the camera and microphone on with no confirmation
**File:** `../vue-chatvideo-layer/app/components/Chatvideo/Call.vue:34-36`, `../sm-demo-app/vue-demo-app/app/pages/call/[conversation].vue:17,30`; same-tab site links in `../vue-chat-layer/app/lib/markdown.ts:22`
**Issue:** `ChatvideoCall` calls `begin()` on mount. `/call/{id}?start=1` starts a call (and rings the other side), and `/call/{id}` joins a live one. The chat renders site paths such as `/call/45?start=1` as same-tab links. One member can DM another `[see this](/call/45?start=1)`, where 45 is their shared DM. A click puts the victim in a call that publishes camera and microphone at once; once the origin has the media permission there is no prompt. A forged `?start=1` also bypasses the call button's intent.
**Fix:** Require an explicit action on the call page. Render a "Start call" or "Join" button and call `begin()` from its click. Keep auto-start only for a `takeStart()` handoff, which proves the click came from `ChatvideoCallButton`, and drop the `?start=1` trigger for direct loads.
### WR-05: A failed LiveKit connect posts `leave`, not `end`, leaving an empty ringing session behind
**File:** `../vue-chatvideo-layer/app/composables/useCall.ts:204-208`
**Issue:** When `r.connect()` fails, the caller posts `client.leave()`. As the starter and only connected participant, the session stays `pending` or `active` with nobody in it:
- the callee keeps ringing for up to 45 s, and Accept joins an empty room;
- Retry (`begin(true)`) gets the existing session back (`Created: false`), so it never rings again until the reconcile job runs.
This is the "left-open session never rings again" bug that `leave()` itself fixed with "last one out ends".
**Fix:** Use the same rule as `leave()`: `void (start || r.remoteParticipants.size === 0 ? client.end(conversationId) : client.leave(conversationId)).catch(() => {})`.
### WR-06: The forum and friends clients have no 401 refresh; an expired token shows "not found"
**File:** `../vue-forum-layer/app/utils/forumClient.ts:139-159`, `../vue-forum-layer/app/components/Forum/Thread.vue:41`, `../vue-userfriends-layer/app/utils/friendsClient.ts:135`
**Issue:** The chat and video clients refresh once on 401 through `$chatAuth.refresh()`. `$forumAuth` and `$userfriendsAuth` are bare token getters, and neither client retries. After the JWT expires, a member on `/forum/t/{id}` sees "thread not found", because `Thread.vue` maps 401 to `forum.error.notFound`. Posting fails the same way. `/chat` keeps working in the same tab.
**Fix:** Give both hooks the `{token, refresh, unauthorized}` shape (the demo can pass `chatAuth`), and add the chat client's single-flight refresh-and-retry to `request()`. Map 401 to its own "signed out" message rather than not-found.
## Info
### IN-01: Group calls in public channels are blocked only by the seeded participant count
**File:** `../sm-demo-app/config/golem15.chatvideo.yaml:13-18`, `../sm-chatvideo-plugin/classes/video_service.go:91,109-112`
**Issue:** The server has no DM-only rule (D-23 is UI only, as documented). Channel calls are refused today only because the four personas plus any visitor exceed `max_participants: 4`. If the cap is raised, or a channel is created with fewer seeded participants, any member can start a call that rings everyone who opened that channel.
**Fix:** Add a host hook in chatvideo (for example an optional `CanStartCall(conversationType)`), or check `conversation.type == dm` in the demo, so the rule does not depend on seed data.
### IN-02: chatvideo checks participant rows, not live membership
**File:** `../sm-chatvideo-plugin/classes/video_service.go:425-443`, `../sm-chatvideo-plugin/classes/authorizer.go:54-79`
**Issue:** A ban revokes the user's JWTs, so new calls are blocked. A LiveKit join token already minted (900 s) and an open `video:session` subscription still survive the ban, because chatvideo never consults `contracts.LiveMembership` as chat does.
**Fix:** Optional: have chatvideo honour `LiveMembership` (via chat's `Service.IsParticipant`) in `requireParticipant` and the authorizer, and remove the participant from the LiveKit room on a ban.
### IN-03: check-local passes member JWTs to curl in argv
**File:** `../sm-demo-app/scripts/check-local.sh:452,514,601` (and the friends checks)
**Issue:** The LiveKit join token is passed carefully through a curl config on stdin, but the member JWTs go into `-H "Authorization: Bearer $jwt"` arguments, which other local users can read in `/proc/*/cmdline`. This is local and uses throwaway users, but it is inconsistent with the stated pattern.
**Fix:** Reuse the `curl -K -` approach with `header = "Authorization: Bearer …"`.
### IN-04: A chat message that starts with "/" can never be posted
**File:** `../vue-chat-layer/app/composables/useChat.ts:825-829`
**Issue:** Every text starting with `/` (for example "/r/golang is great", or a path) is dispatched as a slash command and refused as unknown.
**Fix:** Dispatch only when the first token matches a known trigger from `commands`, or support a `//` escape.
### IN-05: `demo:seed` brings back the default moderation lists after an admin empties them
**File:** `../sm-demo-app/plugins/demo/console/seed_friends.go:117-122`
**Issue:** `seedModerationLists` refills both lists whenever both are empty, and every release runs `demo:seed`. An admin who deliberately clears both lists gets the defaults back at the next deploy.
**Fix:** Seed only on the first run, for example by inserting the lists in a row-creation step or recording a seeded flag.
### IN-06: nginx `/livekit/` exposes LiveKit's whole HTTP surface
**File:** `../sm-demo-app/deploy/nginx/demo.summercms.io.conf:363-375`
**Issue:** Browsers need only `/rtc` (and `/rtc/validate`). The location also exposes LiveKit's Twirp `RoomService` on the public origin. That service is protected by admin JWTs signed with the API secret, so exposing it publicly is unnecessary.
**Fix:** `location ^~ /livekit/rtc { proxy_pass http://127.0.0.1:7890/rtc; … }`, and answer 404 for the rest of `/livekit/`. Adjust check-deploy.
### IN-07: The incoming-call runtime can leak a session handle and survives logout
**File:** `../vue-chatvideo-layer/app/composables/useIncomingCalls.ts:41-56,58-70`
**Issue:** Two `video:call:incoming` events for the same session, both pending in `subscribe`, both pass the `pending.value?.sessionId === call.sessionId` check. The second overwrites `rt.channel` and the first handle is never closed. `pending` is also never cleared on logout (see WR-01).
**Fix:** Keep a sequence number per `watchSession` and close the handle unless it is the latest. Clear `pending` from the logout path.
### IN-08: The VAPID runbook pipeline runs without `pipefail`
**File:** `../sm-demo-app/DEPLOY.md:533-540`
**Issue:** The remote script uses `set -e` without `pipefail`. If `herald:vapid-generate` fails, `jq` emits nothing and `tee -a` appends nothing, but the `SUBJECT` line is still appended. The re-run guard (`grep -c "^…VAPID__" = 0`) then refuses ("already in .env") although no key pair exists, and recovery needs a hand edit of `.env`. The keys were already applied on rome; this matters for a rebuild.
**Fix:** Use `set -eo pipefail`, generate into a variable first, check both keys are non-empty, then append all three lines in one `tee`.
### IN-09: `GET /chat/members` writes, and builds an unbounded `IN` list
**File:** `../sm-demo-app/plugins/demo/chat_api.go:338-358`
**Issue:** A GET assigns handles (`AssignHandle`) to members without one. Two concurrent first loads race on the same rows. It also expands every member id into `id IN ?`, which fails with 500 past Postgres' 65535 bind-parameter limit. The production reset schedule is off, so users accumulate.
**Fix:** Select with the `memberWhere` subquery (`WHERE id <> ? AND <memberWhere>`) instead of an id list. Assign missing handles at registration or in `demo:seed`, not in a GET.
---
_Reviewed: 2026-11-01T23:50:42Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,153 @@
---
phase: "20"
slug: "demo-instance-with-every-shared-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-11-05"
---
# Phase 20 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Static audit of repository contents (no SSH, no ls-remote, no host contact). Audited: sm-demo-app da03c06 with plugin submodules on their tags (chat v0.1.5, forum v0.1.2, moderation v0.1.1, chatvideo/herald/userfriends v0.1.1, chatchannels/chatbots v0.1.0); vue-demo-app pins vue-chat-layer v0.1.1, vue-chatvideo-layer v0.1.1, vue-forum-layer v0.1.0, vue-userfriends-layer v0.1.0. Paths are relative to `../` (the meta repo). One medium threat (T-20-32) is open and non-blocking.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| chat/forum HTTP clients → plugin APIs | message and post bodies untrusted; the saving seam runs before storage | message content, thread title, Markdown, JWT (high) |
| host listeners → plugin services | the host decides refusals; listener errors must not leak internals | saving events, Rejection, errors (medium) |
| admin settings → moderation filter | admins edit lists every message is matched against | banned word/link entries (medium) |
| build machine → git.golem15.com / proxies / origin | tags become immutable inputs of every consumer | tags, pushes, go.sum, pnpm lock (medium) |
| browser → /_demo, /_chat, /_chatchannels, /_chatbots, /_chatvideo, /_forum, /_userfriends | every request untrusted; actor only from the JWT | JWT, JSON, uploads ≤5 MiB (high) |
| Centrifugo subscribe proxy → Go / browser → Centrifugo | client channel names, authorized server-side | channel names, connection tokens, X-Centrifugo-Secret (high) |
| seed/reset commands → DB | trusted operator commands; persona accounts must stay unusable | persona rows, random bcrypt hashes (medium) |
| Go → LiveKit RoomService (loopback) / browser → LiveKit | admin calls signed with the API secret; WebRTC with short-lived join token | admin JWT, join token (TTL 900), media, ICE (high) |
| members' content → other members' browsers | untrusted text, links, cards, content_html, names | rendered content (high) |
| realtime events → incoming-call prompt | server payloads about other users | session_id, conversation_id, caller_name (low) |
| npm / git → layers | third-party (livekit-client) and own layers | packages, lockfile integrity (medium) |
| internet → nginx (Cloudflare) / rome public 7891/tcp, 7892/udp | six API prefixes plus /livekit/; WebRTC media straight to LiveKit | HTTP, WS, SRTP, ICE (high) |
| build machine → rome (SSH) | secrets generated on rome; releases and rsync | binaries, configs (high) |
| demo services ↔ WavePath on the same host | must stay isolated | ports, supervisor programs, LiveKit config (high) |
| test harnesses / browser checks → local and live demo | throwaway accounts, visitor creds, live JWT files | JWTs (0600 files), probe content (medium) |
| build machine → summercms.io | journal token publishing changelog posts | g15_ token (high) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-20-01 | Tampering | chat write paths that skip the seam | high | mitigate | MessageSaving fired on the only message insert and content update (sm-chat-plugin classes/service.go:314-337, writes.go:267-276); forum equivalents; TestMessageSavingTracer/Rejects. Chatbots announce path inserts without the seam but is unreachable on the demo (Ephemeral commands) | closed |
| T-20-02 | Information disclosure | listener errors in API bodies | medium | mitigate | only Rejection → 422, everything else opaque 500 (chat/forum api/errors.go) on every write handler | closed |
| T-20-03 | Denial of service | filter matching cost | medium | mitigate | QuoteMeta + RE2, recompile only on list change; 500 entries / 200 chars enforced in BeforeValidate | closed |
| T-20-04 | Elevation of privilege | moderation settings screen | medium | mitigate | `PermissionManageSettings`, role `developer`, enforced by cabana | closed |
| T-20-05 | Tampering | one-way module tags | medium | mitigate | all family tags annotated, on origin/main, monotonic | closed |
| T-20-06 | Elevation of privilege | channel creation by members | high | mitigate | `CanManageContext` always false (plugins/demo/classes/chat_context.go:272-274); tests refuse member and moderator | closed |
| T-20-07 | Information disclosure | subscribing to other conversations | high | mitigate | chat/user/video authorizers admit only own or participant channels; all namespaces via subscribe proxy. Residual IN-02 | closed |
| T-20-08 | Spoofing | persona accounts | medium | mitigate | `personas.demo.invalid` emails, random bcrypt hashes never printed; reset reseeds | closed |
| T-20-09 | Elevation of privilege | moderator rights | medium | mitigate | group conversations only, member + moderator group, banned users excluded, live membership required; group seeded empty | closed |
| T-20-10 | Tampering | spam/abuse in chat and forum | high | mitigate | moderation listeners on both seams; rate buckets (chat 120/min, DM 10/min, forum, video-initiate 5/min); Turnstile `closed`; manual demo:reset (schedule off in production). Residual WR-03 | closed |
| T-20-11 | Denial of service | realtime token minting | low | mitigate | JWT + membership guard, 120/min per user | closed |
| T-20-12 | Information disclosure | LiveKit and VAPID secrets (local) | high | mitigate | dev.sh umask 077, names only echoed; empty examples; CHANGE_ME placeholders enforced by check-deploy.sh:391-399 | closed |
| T-20-13 | Denial of service | stale chat-family rows after reset | low | mitigate | reset lists chat-family tables before users; reset tests | closed |
| T-20-14 | Tampering | stored XSS (text, names, URLs) | high | mitigate | vue-chat-layer v-html only through the escaping renderer; safeUrl, rel noopener noreferrer nofollow; linkify on escaped text with honestHttpUrl | closed |
| T-20-15 | Information disclosure | JWT handling in the layer | medium | mitigate | token from auth.token() as Bearer to same-origin bases; no web storage or cookie use | closed |
| T-20-16 | Information disclosure | attachment downloads | medium | mitigate | bearer download, object URL; server checks CanView | closed |
| T-20-17 | Spoofing | slash command answers | low | mitigate | `/` text dispatched, never posted; local ephemeral row marked "only you" | closed |
| T-20-18 | Tampering | layer supply chain | medium | mitigate | exact pins; layers pinned by tag and locked to commits | closed |
| T-20-19 | Denial of service | realtime connections | low | mitigate | single Centrifuge client; bounded polling | closed |
| T-20-20 | Tampering | forum stored XSS | high | mitigate | v-html only on server-rendered content_html; `breakwater.Render` (bluemonday) on write; server-side preview | closed |
| T-20-21 | Spoofing | call joins | high | mitigate | requireParticipant on start/join, token TTL 900, max 4 participants; layer never builds tokens | closed |
| T-20-22 | Information disclosure | camera and microphone | medium | mitigate | media only after handoff/click; disconnect stops tracks; leave on pagehide/unmount | closed |
| T-20-23 | Tampering | incoming-call payloads | low | mitigate | text binding for caller name; positive integer ids only | closed |
| T-20-24 | Denial of service | call spam | medium | mitigate | video-initiate 5/min, max_duration 900, reconcile job; DM-only slot in the UI. Residual IN-01 | closed |
| T-20-25 | Information disclosure | friend enumeration | low | mitigate | generic 422 shown as is; directory = chat picker set | closed |
| T-20-26 | Information disclosure | VAPID private key | high | mitigate | never printed; empty example; no `/_herald` route in Nuxt or nginx, enforced by check-deploy.sh:405-409 | closed |
| T-20-27 | Elevation of privilege | LiveKit co-tenancy with WavePath | high | mitigate | own supervisor program, config, user, ports, key pair; WavePath config untouched (DEPLOY.md:355-410). Host state live-only | closed |
| T-20-28 | Information disclosure | LiveKit, VAPID, Centrifugo secrets (rome) | high | mitigate | generated on rome, `install -m 0640 root:summercms-demo`, written via stdin, unset; placeholders enforced. File modes live-only | closed |
| T-20-29 | Denial of service | open media ports | medium | mitigate | only 7891/tcp and 7892/udp via Vuurmuur; participant and timeout limits. Firewall live-only | closed |
| T-20-30 | Information disclosure | origin IP in ICE candidates | low | accept | AR-20-01 | closed |
| T-20-31 | Elevation of privilege | unintended Go routes | medium | mitigate | six chat-family locations + `/livekit/`, no /_herald, `/backend` 404; check-deploy.sh. Residual IN-06 | closed |
| T-20-32 | Tampering | firewall drift | medium | mitigate | Vuurmuur change and removal documented (DEPLOY.md:467-490), but **no dated rules.conf backup**; DEPLOY.md:475-476 points to a "Probed on rome on 2026-10-31" section (:94-102) that lists no rules | open — below high threshold (non-blocking) |
| T-20-33 | Denial of service | a release breaking the live demo | medium | mitigate | `.prev` binary and site kept, rollback documented, check-live after release; clean-tree builds | closed |
| T-20-34 | Spoofing | signalling WebSocket | low | mitigate | tokens only after requireParticipant (TTL 900); `/rtc/validate` 401 without token | closed |
| T-20-35 | Information disclosure | credentials in tests and check output | medium | mitigate | visitor password read from `.env`, never printed; failures name keys only. Residual IN-03 (argv) | closed |
| T-20-36 | Tampering | regressions behind skipped tests | medium | mitigate | check-all.sh runs suites without `-short`; container start failure fails tests; PLAN verifies count `--- PASS` lines | closed |
| T-20-37 | Denial of service | throwaway accounts on the live demo | low | accept | AR-20-02 (rationale corrected) | closed |
| T-20-38 | Spoofing | banned-link matching | high | mitigate | sm-moderation-plugin normalize.go canonical links (dots, percent-decode, last `@`, port, IDNA, path); TestMatchBypassLinks | closed |
| T-20-39 | Tampering | banned-word matching | high | mitigate | entity decode, NFKC, Cf/Cc drop, space folding, diacritics, markup strip; two passes; TestMatchBypassWords | closed |
| T-20-40 | Tampering | forum Markdown rendered after the check | high | mitigate | PostSaving.HTML carries rendered HTML; demo runs CheckHTML on it; forum, moderation and demo tests | closed |
| T-20-41 | Denial of service | normalising and matching large texts | low | accept | AR-20-03 | closed |
| T-20-42 | Information disclosure | useCall: media after leave | critical | mitigate | per-attempt `stale()` checks at every await; stale branch drops the Room; tests cover seven moments | closed |
| T-20-43 | Elevation of privilege | /call/{id} opened from a link | high | mitigate | confirmation unless an in-memory 60 s single-use handoff exists | closed |
| T-20-44 | Information disclosure | chat store, socket, call prompt after logout | high | mitigate | isolated sign-out steps; reset, disconnect, dismiss wired; communityAuth tests; check-chat.cjs requires `/ws` closed in 5 s | closed |
| T-20-45 | Denial of service | empty ringing session after a failed connect | medium | mitigate | closeFailed ends sessions with ≤1 participant; tests | closed |
| T-20-46 | Spoofing | deceptive hosts in anchors and cards | high | mitigate | `honestHttpUrl` (vue-chat-layer urlGuard.ts:28-48) on every link path; tests | closed |
| T-20-47 | Tampering | release tags | high | mitigate | annotated, on origin/main, monotonic; ls-remote and fast-forward recorded in 20-09-SUMMARY | closed |
| T-20-48 | Information disclosure | live member JWTs | high | mitigate | mode 600/400 required, header via `curl -K -` stdin; delete afterwards documented and recorded. Deletion live-only | closed |
| T-20-49 | Tampering | probe content on the live demo | medium | mitigate | scope limited to own DM and seeded thread; stored probes deleted and reported; controls refused first | closed |
| T-20-50 | Information disclosure | journal token | high | mitigate | changelog-post.py own token file, refuses readable-by-others, never printed | closed |
| T-20-51 | Denial of service | the shared host during a release | medium | mitigate | restarts only the demo programs; rollback; pids recorded. Pid check live-only | closed |
| T-20-52 | Tampering | temporary integration leaking into a release | medium | mitigate | clean-tree builds, GOWORK=off, frozen lockfile; tag-only pins; no go.mod replace | closed |
| T-20-SC | Tampering | npm / Go installs (plans 01-09) | high | mitigate | every new module matches an already-vetted version (sitemap or framework); livekit-client 2.23.0 exact with lock integrity and human checkpoint; `protobufjs: false`; later plans change only golem15 modules and layer tags | 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-20-01 | T-20-30 | rome already exposes services on its public address; the ICE candidates show it (DEPLOY.md:367-369). | 20-05 PLAN | 2026-11-05 |
| AR-20-02 | T-20-37 | check-live registers no accounts (its token-less register expects 422). **Corrected:** the plan said the reset job removes the human UAT account, but the reset schedule is off in production (DEPLOY.md:945-946, 1006-1009), so UAT accounts stay until someone runs `demo:reset --force` by hand. | 20-09 PLAN (corrected by audit) | 2026-11-05 |
| AR-20-03 | T-20-41 | RE2 is linear, lists are bounded by max_entries and max_entry_length, and the work is two passes over a message the plugins already cap in size. | 20-07 PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Open Below Threshold
| Threat | Severity | Gap | To close (any one) |
|--------|----------|-----|--------------------|
| T-20-32 | medium | no dated `rules.conf` backup before the Vuurmuur change; DEPLOY.md's substitute reference points at a section without the rules | take a dated `rules.conf.bak-<date>` on rome, record the 15 pre-change RULE lines in DEPLOY.md's "Probed on rome" section, or record an accepted risk |
---
## Unregistered Flags and Residual Risk (non-blocking)
- **Unregistered (20-02):** `GET /_demo/api/v1/chat/members` (sm-demo-app/plugins/demo/chat_api.go) lists every member's name, surname and handle to any member; IN-09 adds that the GET writes handles and builds an unbounded `IN` list (500 above 65535 members). Todo `demo-chat-members-read-only.md`.
- Deferred review items, each with a pending todo: WR-02 (any member can pin/unpin in public channels), WR-03 (profile names, friend-group names and attachment filenames are not moderated), WR-06 (forum/friends clients lack 401 refresh), IN-01 (no server-side DM-only call rule), IN-02 (video join token survives a ban up to 900 s), IN-03 (member JWTs and — found by this audit — the visitor password at check-local.sh:333 in curl argv), IN-04, IN-05, IN-06 (nginx `/livekit/` exposes LiveKit's whole HTTP surface incl. admin-JWT-protected RoomService), IN-07, IN-08 (VAPID runbook without `pipefail`).
- Untracked: the 19.4.1 Centrifugo secrets runbook (DEPLOY.md:327-328) passes secrets as `sed` arguments on rome (not echoed; noted in 20-05 deviation 3, not fixed).
### Host-only checks (not verifiable from the repos)
WavePath LiveKit untouched and pid unchanged (T-20-27, T-20-51); file modes of `livekit.yaml`, `.env`, `centrifugo.json` (T-20-28); the applied Vuurmuur rule (T-20-29, T-20-32); live JWT files deleted (T-20-48); `RESET__ENABLED` count 0.
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 53 | 52 | 1 (medium, non-blocking) | msd-security-auditor (ASVS L1, block_on high; static, repo contents only) |
---
## 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
- [ ] T-20-32 closed (backup or recorded rules) or accepted
- [ ] Host-only checks confirmed on rome
**Approval:** verified 2026-11-05 (ASVS L1; T-20-32 open below threshold)

View File

@@ -0,0 +1,118 @@
---
phase: "20"
slug: "demo-instance-with-every-shared-plugin"
# 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 20 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | go test (plugins, sm-demo-app; testcontainers Postgres on full runs); vitest 3.2.7 (vue-demo-app, vue-chat-layer); node --test or vitest (other layers) |
| **Config file** | `../sm-demo-app/vue-demo-app/vitest.config.ts`; new layers: none — Wave 0 installs |
| **Quick run command** | `go vet ./... && go test -short ./...` in the touched Go repo; `pnpm test` in the touched layer |
| **Full suite command** | `cd ../sm-demo-app && scripts/check-all.sh` |
| **Estimated runtime** | ~30 seconds quick, ~300 seconds full |
---
## Sampling Rate
- **After every task commit:** Run the quick run command of the repo touched
- **After every plan wave:** Run sm-demo-app `go test ./...` (Docker) + `pnpm --dir vue-demo-app test` + `scripts/check-pins.sh`
- **Before `/msd-verify-work`:** Full suite must be green
- **Max feedback latency:** 60 seconds
---
## Per-Task Verification Map
Filled by the planner from the plans; source map in `20-RESEARCH.md` § Validation Architecture.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 20-01-01 | 01 | 1 | D-18, SC-1 | T-20-01, T-20-02 | a rejecting MessageSaving listener gives 422 and stores nothing; no listener changes nothing | integration (Docker) | `go -C ../sm-chat-plugin test -count=1 -run '^TestMessageSavingTracer$' -v .` | ❌ created in task (saving_smoke_test.go) | ⬜ pending |
| 20-01-02 | 01 | 1 | D-18 | T-20-01 | forum thread, reply and edit refused before render | integration (Docker) | `go -C ../sm-forum-plugin test -count=1 -run '^TestPostSavingTracer$' -v .` | ❌ created in task | ⬜ pending |
| 20-01-03 | 01 | 1 | D-17 | T-20-03, T-20-04 | word and link matching; list caps; settings behind a permission | unit | `go -C ../sm-moderation-plugin test -count=1 -run '^TestFilterSmoke$' -v ./classes` | ❌ new repo | ⬜ pending |
| 20-01-04 | 01 | 1 | SC-1 | T-20-05 | modules resolve by tag only | build | `for r in …8 repos; do go -C ../$r vet ./... && go -C ../$r test -short ./...; done` + `GOWORK=off go build` of 5 dependants | ✅ | ⬜ pending |
| 20-01-05 | 01 | 1 | — | T-20-05 | go-ahead before one-way tags | checkpoint | — | — | ⬜ pending |
| 20-01-06 | 01 | 1 | SC-1 | T-20-05 | remote tags equal verified HEADs | script | `git ls-remote origin refs/tags/<tag>^{}` loop | ✅ | ⬜ pending |
| 20-02-01 | 02 | 2 | SC-1, D-06, D-07 | T-20-06, T-20-07 | member posts in #general; channel creation refused | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestDemoChatTracer\|TestGoModRequiresTags\|TestBootAllPlugins)$' -v .` + `scripts/check-pins.sh` | ❌ created in task (chat_smoke_test.go) | ⬜ pending |
| 20-02-02 | 02 | 2 | D-05, D-08, D-12, D-14, D-17, D-22, D-23 | T-20-09, T-20-10 | banned words and links refused in chat and forum | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^TestDemoModerationSmoke$' -v .` + `scripts/build.sh dev` | ❌ created in task | ⬜ pending |
| 20-02-03 | 02 | 2 | D-09 | T-20-08, T-20-13 | personas cannot log in; reset empties every chat-family table | integration (Docker) | `go -C ../sm-demo-app test -count=1 -run '^(TestSeedChatSmoke\|TestResetKeepsPlans\|TestSeedRoadmapUpsert)$' -v .` | ❌ / ✅ | ⬜ pending |
| 20-02-04 | 02 | 2 | D-06, D-10 | T-20-11, T-20-12 | realtime token only for live members; no secret committed | unit + script | `go -C ../sm-demo-app test -short .` + `centrifugo checkconfig` + `scripts/dev.sh up` + `scripts/check-local.sh realtime` | ✅ | ⬜ pending |
| 20-03-01 | 03 | 3 | D-01, D-04, D-07, SC-2 | T-20-15 | anonymous prompt; Bearer only to same-origin APIs | unit + browser | `pnpm --dir ../vue-chat-layer test` + vue-demo-app build + `scripts/check-local.sh chat` | ❌ created in task | ⬜ pending |
| 20-03-02 | 03 | 3 | D-02, SC-2 | T-20-14, T-20-19 | text escaped; one realtime connection | unit + browser | layer and vue-demo-app tests + `scripts/check-local.sh chat realtime remote` | ✅ (app) | ⬜ pending |
| 20-03-03 | 03 | 3 | D-02, D-05, D-22, D-23 | T-20-14, T-20-16, T-20-17 | linkify on escaped HTML; authorised downloads; ephemeral answers stay local | unit + build | `pnpm --dir ../vue-chat-layer test` + `build:playground` + `scripts/check-local.sh chat` | ✅ | ⬜ pending |
| 20-03-04 | 03 | 3 | D-03, D-04 | — | floating panel hidden for anonymous visitors | browser | `scripts/check-local.sh tour chat` | ✅ extend | ⬜ pending |
| 20-03-05 | 03 | 3 | D-01 | — | go-ahead before the public tag | checkpoint | — | — | ⬜ pending |
| 20-03-06 | 03 | 3 | D-01 | T-20-18 | layer pinned by tag with a committed lockfile | script | remote tag equals HEAD + pin grep + vue-demo-app test and build | ✅ | ⬜ pending |
| 20-04-01 | 04 | 4 | D-14, D-21, D-22 | T-20-20 | posts render only server content_html | unit + script | `pnpm --dir ../vue-forum-layer test` + build + `scripts/check-local.sh forum` | ❌ created in task | ⬜ pending |
| 20-04-02 | 04 | 4 | D-15 | T-20-25 | friend refusals stay generic | unit + script | `pnpm --dir ../vue-userfriends-layer test` + `scripts/check-local.sh friends forum chat` | ❌ created in task | ⬜ pending |
| 20-04-03 | 04 | 4 | D-13 | T-20-SC | livekit-client legitimacy confirmed before install | checkpoint (blocking-human) | — | — | ⬜ pending |
| 20-04-04 | 04 | 4 | D-11, D-12, D-13, D-23 | T-20-21, T-20-22, T-20-23, T-20-24 | server-minted tokens; call button only in DMs; media only after start or accept | unit + script | `pnpm --dir ../vue-chatvideo-layer test` + build + `scripts/check-local.sh call chat` | ❌ created in task | ⬜ pending |
| 20-04-05 | 04 | 4 | D-16 | T-20-26 | VAPID keys only in .env | script | `scripts/dev.sh up` + `scripts/check-local.sh herald tour` | ❌ check added in task | ⬜ pending |
| 20-04-06 | 04 | 4 | D-14, D-15, D-13 | — | go-ahead before public tags | checkpoint | — | — | ⬜ pending |
| 20-04-07 | 04 | 4 | SC-2 | T-20-SC | layers pinned by tag | script | remote tags equal HEAD + vue-demo-app test, build + `scripts/check-local.sh forum friends call chat tour` | ✅ | ⬜ pending |
| 20-05-01 | 05 | 5 | D-10, D-12, SC-3 | T-20-31 | exact Go allow-list; /_herald unrouted; ports and caps agree | script | `scripts/check-deploy.sh` (last line `check-deploy: ok`) | ✅ extend | ⬜ pending |
| 20-05-02 | 05 | 5 | SC-1 | T-20-33 | release build from tags, smoke clean | script | `scripts/check-pins.sh` + `scripts/build.sh release` + `scripts/smoke.sh` | ✅ | ⬜ pending |
| 20-05-03 | 05 | 5 | SC-3 | T-20-27 | go-ahead before rome changes | checkpoint | — | — | ⬜ pending |
| 20-05-04 | 05 | 5 | SC-2, SC-3, D-10, D-16 | T-20-27, T-20-28, T-20-34 | WavePath untouched; secrets generated on rome | live | `scripts/check-live.sh https://demo.summercms.io` | ✅ extend | ⬜ pending |
| 20-05-05 | 05 | 5 | D-10 | T-20-29, T-20-32 | go-ahead before the firewall change | checkpoint | — | — | ⬜ pending |
| 20-05-06 | 05 | 5 | D-10 | T-20-29, T-20-32 | only 7891/tcp and 7892/udp opened, through Vuurmuur | live | `ssh … iptables -S` count + `nc -zv … 7891` | ✅ | ⬜ pending |
| 20-06-01 | 06 | 6 | D-17, D-18, SC-4 | T-20-10 | settings to filter to listener to 422 | unit + integration | `go -C ../sm-moderation-plugin test ./...` + `-run '^TestDemoModeration$'` | ❌ created in task | ⬜ pending |
| 20-06-02 | 06 | 6 | D-18, D-17 | T-20-01, T-20-02 | parity without listeners; refusal on every write path | unit + integration | chat, forum and moderation full suites | ❌ created in task | ⬜ pending |
| 20-06-03 | 06 | 6 | D-05 to D-09, D-11, D-12, D-22, D-23, SC-1 | T-20-06, T-20-07, T-20-09, T-20-11 | membership, refused channel create, moderator scope, member-only tokens | integration | `go -C ../sm-demo-app test -count=1 . ./plugins/demo/...` + named tests | ❌ created in task | ⬜ pending |
| 20-06-04 | 06 | 6 | D-01, D-02, D-04, D-05, D-13, D-14, D-15, D-21, D-23 | T-20-14 | escaping and DM-only slot pinned | unit | `pnpm test` in four layers + vue-demo-app test and build | ❌ created in task | ⬜ pending |
| 20-06-05 | 06 | 6 | SC-2, D-11, D-13, D-23 | — | two-browser realtime; DM call with fake media | browser E2E | `scripts/check-local.sh chat chatlive callmedia forum friends call herald tour realtime remote` | ❌ created in task | ⬜ pending |
| 20-06-06 | 06 | 6 | — | — | go-ahead before pushes and fix tags | checkpoint | — | — | ⬜ pending |
| 20-06-07 | 06 | 6 | SC-2, SC-3, SC-4 | T-20-36 | full gate including check-live | gate | `scripts/check-all.sh --local` (last line `check-all: ok`) | ✅ extend | ⬜ pending |
| 20-06-08 | 06 | 6 | SC-2, SC-3 | — | two-browser chat and DM call on the live demo | manual (UAT) | — | — | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
Each gap is closed inside the plan that creates the code (a smoke test or check in the same task), and the full tests come in plan 06 (project rule: unit tests are the last plan).
- [ ] `sm-demo-app/seed_chat_test.go`, `chat_test.go` (context, channel adapter, resolvers, DM calls, bootstrap JSON): smoke in 20-02 (`chat_smoke_test.go`), full in 20-06 Task 3
- [ ] `sm-chat-plugin` and `sm-forum-plugin` pre-save seam tests: smoke in 20-01 Tasks 1-2, full in 20-06 Task 2
- [ ] `sm-moderation-plugin` package and tests (new repo): smoke in 20-01 Task 3, full in 20-06 Tasks 1-2
- [ ] `vue-chat-layer` vitest config and ported bm chat tests (composable store setup): config and smoke in 20-03 Task 1, ported tests in 20-06 Task 4
- [ ] `vue-chatvideo-layer`, `vue-forum-layer`, `vue-userfriends-layer` `tests/*.test.ts`: smoke in 20-04 Tasks 1, 2 and 4, full in 20-06 Task 4
- [ ] `scripts/dev.sh` local livekit-server (20-02 Task 4); `scripts/check-local.sh chat` (20-03 Task 1), `forum`, `friends`, `call`, `herald` (20-04), `chatlive` and `callmedia` (20-06 Task 5)
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Two-browser chat realtime and a DM video call on the live demo | SC-2, D-13 | Real media over NAT'd browsers and the rome firewall | Two accounts on https://demo.summercms.io: send a message, see it arrive live; start a DM call, see and hear both tiles, mute, leave |
| Vuurmuur firewall rule on rome | D-10 | Shared production host change | Apply with the user's go-ahead; confirm with `/livekit/rtc/validate` and a call |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies (checkpoints excepted)
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [x] No watch-mode flags (vitest run, node --test)
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending

View File

@@ -0,0 +1,72 @@
# API Coverage — Phase 20 (livekit-client, LiveKit server, Centrifugo, Web Push)
> Full coverage by default. Opt-outs are explicit, reasoned decisions. The phase builds no new server-side client: LiveKit's server API is called by `sm-chatvideo-plugin` (server-sdk-go), Centrifugo by `lighthouse`, Web Push by `sm-herald-plugin`. This phase adds the browser side of the video calls (`vue-chatvideo-layer` on `livekit-client` 2.23.0, plan 04), the chat's use of Centrifugo (`vue-chat-layer`, plan 03) and herald's VAPID configuration (plan 04).
## livekit-client 2.23.0 (plan 04, vue-chatvideo-layer)
| capability | decision | reason |
|---|---|---|
| Room connect (livekit_url and the server-minted join token) | INTEGRATE | |
| Room disconnect (stops the local tracks) | INTEGRATE | |
| publish camera (localParticipant.setCameraEnabled) | INTEGRATE | |
| publish microphone (localParticipant.setMicrophoneEnabled) | INTEGRATE | |
| subscribe and attach remote tracks (TrackSubscribed, track.attach and detach) | INTEGRATE | |
| mute and unmute | INTEGRATE | |
| camera toggle | INTEGRATE | |
| participant events (ParticipantConnected, ParticipantDisconnected, TrackMuted, TrackUnmuted, LocalTrackPublished) | INTEGRATE | |
| connection state and reconnection (Reconnecting, Reconnected, Disconnected) | INTEGRATE | |
| adaptiveStream and dynacast | INTEGRATE | |
| audio playback unlock (AudioPlaybackStatusChanged, startAudio) | INTEGRATE | |
| dynamic import of the package | INTEGRATE | |
| screen share | OPT-OUT | explicitly out of scope: D-13 keeps the call page minimal and the server config has screen_share false |
| data channel (publishData, data streams, RPC) | OPT-OUT | not needed: chat messages go through the chat plugins, not the call |
| device selection (switchActiveDevice, getLocalDevices) | OPT-OUT | not needed for a minimal demo call: the browser's default camera and microphone are used |
| end-to-end encryption (E2EE worker) | OPT-OUT | explicitly out of scope: a public demo call with a short-lived room; media is DTLS-SRTP encrypted in transit |
| recording and egress | OPT-OUT | explicitly out of scope: the demo records nothing |
| simulcast tuning (videoEncoding, layers) | OPT-OUT | not needed: the defaults with adaptiveStream and dynacast fit 1:1 calls |
| audio-only mode | OPT-OUT | not needed: turning the camera off gives the same call without video |
| background effects and track processors | OPT-OUT | not needed: not part of a minimal call page |
| active speaker highlighting | OPT-OUT | not needed: calls are 1:1 DMs (D-11), so there is no speaker to pick out |
## LiveKit server 1.13.1 through golem15.chatvideo (plans 02 and 05)
| capability | decision | reason |
|---|---|---|
| room create (on video/start) | INTEGRATE | |
| room delete (on video/end and at max_duration) | INTEGRATE | |
| join token mint (per participant, token_ttl 900) | INTEGRATE | |
| participant count and active rooms (reconcile) | INTEGRATE | |
| remove participant (on video/leave) | INTEGRATE | |
| reconcile job (chatvideo:reconcile-sessions ends idle and overdue rooms) | INTEGRATE | |
| room max_participants (4 in livekit.yaml, next to chatvideo max_participants, D-12) | INTEGRATE | |
| /rtc/validate (check-local call) | INTEGRATE | |
| egress (recording, streaming) | OPT-OUT | explicitly out of scope: the demo records nothing |
| ingress (RTMP, WHIP) | OPT-OUT | explicitly out of scope: calls start from the site only |
| SIP | OPT-OUT | explicitly out of scope: no phone dial-in on a public demo |
| webhooks | OPT-OUT | not needed: the reconcile job polls room state, and the call events go through Centrifugo |
| TURN server | OPT-OUT | deferred: plan 05 decides it with the rome network setup (ICE TCP 7891 and UDP 7892 first) |
## Centrifugo v6 (plans 02 to 04)
| capability | decision | reason |
|---|---|---|
| chat namespace with presence and join/leave | INTEGRATE | |
| user namespace (user:{id}, the chat's events and video:call:incoming) | INTEGRATE | |
| video namespace (video:session:{id}: accepted, ended, participant joined and left) | INTEGRATE | |
| subscribe proxy on chat, user and video | INTEGRATE | |
| one shared client per page (vue-chat-layer realtime, onUserEvent for other layers) | INTEGRATE | |
| server HTTP API publish (lighthouse broadcast jobs) | INTEGRATE | |
| history and recovery | OPT-OUT | not needed: the chat refetches the open conversation over HTTP on every (re)subscribe |
| client-side publish | OPT-OUT | explicitly out of scope: browsers never publish; Go publishes after authorizing the write |
| client RPC | OPT-OUT | not needed: every action is an HTTP call |
| presence on the video namespace | OPT-OUT | not needed: LiveKit's participant events show who is in the call |
## Web Push through golem15.herald (plan 04)
| capability | decision | reason |
|---|---|---|
| VAPID key pair in .env (herald:vapid-generate, D-16) | INTEGRATE | |
| VAPID public key route (/_herald/api/v1/push/vapid-public-key, check-local herald) | INTEGRATE | |
| subscribe UI and service worker | OPT-OUT | deferred per D-16: herald is mounted without UI in this phase |
| push on chat events (DMs, mentions, incoming calls) | OPT-OUT | deferred per D-16: needs the subscribe UI first |
| push to /_herald from the site (goPaths) | OPT-OUT | deferred per D-16: the site does not call herald, so /_herald is not routed by vue-demo-app |

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,206 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
plan: 05
subsystem: auth
tags: [mcp, oauth, release, changelog, scopes]
requires:
- phase: 32.1-01
provides: MCP scope list and MCPCall on the tool-call context
- phase: 32.1-02
provides: OAuth implied scopes in sm-user-plugin
- phase: 32.1-03
provides: MCP consent replace, kind, and backfill
- phase: 32.1-04
provides: vue-oauth-layer /connect
provides:
- "Unit tests for a bare MCP call, both guards, PRM scopes, implied scopes, consent replace, kind, and backfill"
- "SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0"
- "Changelog post https://summercms.io/changelog/summercms-0-5-2"
affects: [33]
actuals:
tokens: 6474
tasks: 4
commits: 1
plan_head_before: 06a144e19f1bca9b918af7adfc44e7b7a1e3f011
plan_head_after: 9094bf9ad99c92b4343d8d3f1062ef4863f58e00
tech-stack:
added: []
patterns:
- "Patch tags are computed from git tag --list at release time, recorded in 32.1-05-release-decision.txt before any ref changes"
- "sm-oauthserver-plugin bumps require versions after those tags are pushed and keeps the summercms replace"
key-files:
created:
- .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
- modules/pact/mcp_call_negative_test.go
- modules/pier/dispatch_mcpcall_test.go
- modules/pier/prm_scopes_test.go
- ../sm-user-plugin/mcp_implies_test.go
- ../sm-oauthserver-plugin/mcp_consent_test.go
- ../sm-oauthserver-plugin/authorizations_kind_test.go
- ../sm-oauthserver-plugin/mcp_backfill_test.go
modified:
- ../sm-oauthserver-plugin/go.mod
- ../sm-oauthserver-plugin/go.sum
- .planning/ROADMAP.md
key-decisions:
- "tag-and-publish. Framework v0.5.2 on master, sm-user-plugin v0.1.16 on phase-32.1-mcp-implied-scopes, sm-oauthserver-plugin v0.1.4 on master after the require bump, vue-oauth-layer v0.1.0 local only."
- "sm-user-plugin v0.1.16 stays on phase-32.1-mcp-implied-scopes and is not retargeted onto v0.1.15. That tag is not an ancestor."
- "vue-oauth-layer has no origin. The tag was not pushed and no remote was created."
patterns-established:
- "Release decision file is written from for-each-ref before the first tag or push and is not rewritten."
requirements-completed: []
coverage:
- id: D1
description: "A bare context has no MCP call, both guards record MCPCall, and PRM uses advertised scopes or falls back."
verification:
- kind: unit
ref: modules/pact/mcp_call_negative_test.go#TestMCPCallFromBareContext
status: pass
- kind: unit
ref: modules/pier/dispatch_mcpcall_test.go#TestDispatchMCPCallBothGuards
status: pass
- kind: unit
ref: modules/pier/prm_scopes_test.go#TestPRMUsesAdvertisedScopes
status: pass
human_judgment: false
- id: D2
description: "OAuth implied scopes do not expand a personal token, write does not grant read, and the subject slice is unchanged."
verification:
- kind: unit
ref: sm-user-plugin/mcp_implies_test.go#TestMCPImpliesPersonalUnchanged
status: pass
human_judgment: false
- id: D3
description: "MCP consent replace, offline_access, revoke, authorization kind, and idempotent backfill."
verification:
- kind: integration
ref: sm-oauthserver-plugin/mcp_consent_test.go#TestMCPConsentReplace
status: pass
- kind: integration
ref: sm-oauthserver-plugin/mcp_backfill_test.go#TestMCPConsentBackfillIdempotent
status: pass
human_judgment: false
- id: D4
description: "Patch tags pushed where origin existed, and the framework changelog post is live."
verification:
- kind: other
ref: python3 plan task-4 verify (exit 0) against 32.1-05-release-decision.txt and https://summercms.io/changelog/summercms-0-5-2
status: pass
human_judgment: false
duration: 34min
completed: 2026-10-28
status: complete
---
# Phase 32.1 Plan 05: Unit tests and patch release Summary
**SummerCMS v0.5.2, sm-user-plugin v0.1.16, sm-oauthserver-plugin v0.1.4, and local vue-oauth-layer v0.1.0 ship the MCP OAuth scope, caller, and consent gaps, with the framework changelog posted**
## Performance
- **Duration:** 34 min
- **Started:** 2026-10-28T15:09:30Z
- **Completed:** 2026-10-28T15:42:50Z
- **Tasks:** 4
- **Files modified:** 12
`actuals.tokens` is chars/4 of `git show` for `9094bf9`, `756797c`, `4f8618e`, and `a7743e7`, plus the decision file and changelog notes (25896 / 4). This summary is not in that count. `commits: 1` is `git rev-list --count 06a144e19f1bca9b918af7adfc44e7b7a1e3f011..9094bf9` at summary time. The metadata commit is after that range.
## Accomplishments
- Framework tests prove a bare context has no MCP call, both guards set one, and PRM uses an advertised list or falls back.
- sm-user-plugin tests keep implied scopes off the stored subject. sm-oauthserver-plugin tests prove consent replace, offline access, revoke, authorization kind, and an idempotent backfill.
- Tags: SummerCMS `v0.5.2` on `9094bf9` (pushed `master`, then the tag), sm-user-plugin `v0.1.16` on `756797c` (pushed `phase-32.1-mcp-implied-scopes`, then the tag), sm-oauthserver-plugin `v0.1.4` on `a7743e7` (pushed `master`, then the tag), vue-oauth-layer `v0.1.0` on `7f805b3` (local only).
- Changelog post id 46: https://summercms.io/changelog/summercms-0-5-2. The plan python verify exited 0.
- No app submodule pointer moved. `go.work` in sm-oauthserver-plugin stayed unstaged.
## Task Commits
The test commits were already on the branches before this continuation. They were not redone.
1. **Task 1: Tracer: bare context has no MCP call, and both guards' tool calls do** - `9094bf9` (test, summercms.go)
2. **Task 2: Plugin, layer, and docs proofs** - `756797c` (test, sm-user-plugin), `4f8618e` (test, sm-oauthserver-plugin). vue-oauth-layer had no new commit in this plan; its tests were already on `7f805b3`.
3. **Task 3: Release checkpoint** - no commit. The accepted option is `tag-and-publish`, recorded in `32.1-05-release-decision.txt` before any tag or push. That file was not rewritten.
4. **Task 4: Tag and publish** - `a7743e7` (chore, sm-oauthserver-plugin) sets `summercms v0.5.2` and `sm-user-plugin v0.1.16` and keeps the summercms replace. Tags and pushes are listed above. No framework commit in this task before the metadata commit.
**Plan metadata:** this summary, the decision file, the changelog notes, and the roadmap check, committed in summercms.go after this file was written.
## Files Created/Modified
- `modules/pact/mcp_call_negative_test.go` - bare context and a FrontendTokenScope literal without Implies
- `modules/pier/dispatch_mcpcall_test.go` - frontend and backend tool calls record MCPCall
- `modules/pier/prm_scopes_test.go` - advertised scopes and the PRMScopes fallback
- `../sm-user-plugin/mcp_implies_test.go` - personal token, write-does-not-grant-read, subject slice unchanged
- `../sm-oauthserver-plugin/mcp_consent_test.go` - replace, offline access, revoke
- `../sm-oauthserver-plugin/authorizations_kind_test.go` - kind mcp or oidc
- `../sm-oauthserver-plugin/mcp_backfill_test.go` - idempotent backfill
- `../sm-oauthserver-plugin/go.mod` - require SummerCMS v0.5.2 and sm-user-plugin v0.1.16
- `../sm-oauthserver-plugin/go.sum` - sum for sm-user-plugin v0.1.16
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt` - checkpoint record and the tag lists from before the release
- `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` - notes published for v0.5.2
- `.planning/ROADMAP.md` - 32.1-05 checked, 5/5 plans executed
## Decisions Made
- The checkpoint option `tag-and-publish` was applied. Versions came from the tag lists in the decision file: next v0.5 patch is v0.5.2, next user v0.1 patch is v0.1.16, next oauth v0.1 patch is v0.1.4, and vue-oauth-layer is v0.1.0 because that tag did not exist.
- sm-user-plugin was tagged on `phase-32.1-mcp-implied-scopes` at `756797c`. `v0.1.15` is not an ancestor. The tag was not moved onto `v0.1.15`. The tag message and the changelog say the missing-scope fields from v0.1.15 are absent. The plugin `go.mod` still requires SummerCMS v0.5.0; the new seams are in v0.5.2, which sm-oauthserver-plugin v0.1.4 requires.
- vue-oauth-layer has no `origin`. `v0.1.0` is local. `git@git.golem15.com:golem15/vue-oauth-layer.git` was not created.
- STATE.md and state.json were not edited and are not in the metadata commit. Current Position stays phase 33.
## Deviations from Plan
### Auto-fixed Issues
None during the release continuation. The test tasks were already committed.
The user-plugin test file is `mcp_implies_test.go` at the plugin root, not `classes/mcp_guard_test.go`. That path was chosen by the earlier test commit and was left as committed.
---
**Total deviations:** 0 auto-fixed during the release continuation
**Impact on plan:** The release followed the accepted checkpoint. The user-plugin tag is intentionally not based on v0.1.15.
## Issues Encountered
- Pushing summercms.go `master` published 44 commits since v0.5.1, including the phase 32 review fixes and planning commits that were already on local master. The push was a fast-forward. No force-push.
- Phase 33 plan 01 reserved the name v0.5.2 for a later tag that would also carry phase 33 seams, and plan 33-14 was going to create that tag. This release took v0.5.2 because it was the next v0.5 patch. Phase 33 must not reuse v0.5.2. The next framework patch is v0.5.3 unless a newer v0.5 tag appears first.
- vue-oauth-layer `README.md` inside `v0.1.0` still says the tag does not exist yet. The release order tagged the existing commit and did not add a README commit. The tag was not moved. "No tag has been pushed" is still true, because there is no remote.
- `state.advance-plan` was not run. It would move Current Position, which is phase 33. Roadmap 32.1-05 was checked by editing ROADMAP.md directly.
## User Setup Required
Create `git@git.golem15.com:golem15/vue-oauth-layer.git`, then push `main` and the annotated tag `v0.1.0` from `/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer`. Do not force-push. Until that remote exists, apps cannot pin the layer. The tagged README still describes the pre-tag install shape.
No journal token was printed, copied, or minted.
## Next Phase Readiness
- The three MCP OAuth gaps are on the tags above. No application go.mod or submodule pointer was bumped.
- Phase 33's release ledger must treat v0.5.2 as already published. Its framework seams belong on a later patch.
- Current Position was left on phase 33.
## Known Stubs
- `vue-oauth-layer/README.md` line 9, inside tag `v0.1.0`: "That tag does not exist yet." The local tag exists; the remote does not. Not edited, because editing would require moving the tag.
## Self-Check: PASSED
- FOUND: `32.1-05-release-decision.txt` (sha256 `3e33d20e4c626c7c43d5da66930ac4b51a0cc02d2995c1fc7e6d5e59f638fa8f`, first line `tag-and-publish`, not rewritten after the tags)
- FOUND: `32.1-05-changelog-notes.md`
- FOUND: summercms.go `9094bf9`, sm-user-plugin `756797c`, sm-oauthserver-plugin `4f8618e` and `a7743e7`
- FOUND remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Local only: vue-oauth-layer `v0.1.0`
- Plan task-4 python verify exit 0. Changelog URL https://summercms.io/changelog/summercms-0-5-2 contains the posted title.
---
*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con*
*Completed: 2026-10-28*

View File

@@ -0,0 +1,29 @@
## Highlights
SummerCMS 0.5.2 lets an MCP OAuth agent use the scopes a plugin declares, and lets a tool call see which subject invoked it. sm-user-plugin v0.1.16 applies read and write as implied scopes for OAuth subjects only. sm-oauthserver-plugin v0.1.4 stores one consent per MCP client, replaces it on a later allow, and backfills live grants. vue-oauth-layer v0.1.0 is the `/connect` page for that consent. Together these versions close the three gaps sm-servers-app needs before MCP OAuth can be switched on: the same tool list as a personal token, attribution of the caller, and per-client revoke.
## Added
- One MCP scope list. Plugins publish member-token scopes, the OAuth server advertises that list (optionally filtered), and the protected-resource metadata uses the same names. An empty list still means the five fixed scopes, including backend. OIDC discovery is unchanged.
- An MCP call on the request context: guard, user, client, token, scopes, session and tool. A request that is not an MCP tool call has none.
- A pending consent view reports whether offline access was requested.
- sm-user-plugin v0.1.16 expands a declared scope when an OAuth grant holds the read or write that the scope marks as implied. A personal token is not expanded, write does not imply read, and the stored subject scopes stay as issued. The dispatch credential still carries the personal token id.
- sm-oauthserver-plugin v0.1.4 records MCP consent per client, shows scope details on the consent JSON, marks authorizations as mcp or oidc, and backfills a consent for each live grant. A second migrate inserts nothing.
- vue-oauth-layer v0.1.0 renders `/connect` from that JSON: checkboxes, read locked, the administrator named as text, and a refetch when the backend link changed. The layer's git remote is not published yet, so an app cannot pin the tag until that repository exists.
## Changed
- A later MCP allow replaces the stored consent scopes. It does not merge them with the previous grant.
- Unticking offline access clears that flag on the issued code. Omitting the scopes field leaves the pending flag as it was.
- Revoking an authorized MCP client deletes its unused code and leaves the user's other clients in place.
- sm-oauthserver-plugin v0.1.4 requires SummerCMS v0.5.2 and sm-user-plugin v0.1.16.
- sm-user-plugin v0.1.16 is not a descendant of v0.1.15. It does not include the missing-scope and missing-permission fields from that tag. Build it with SummerCMS v0.5.2; its go.mod still states v0.5.0 and selects the newer framework only when the application does.
## Fixed
- A hidden relation field or partial field no longer keeps its routes after it is removed from the form.
- Record events fire when a hasMany child is linked, unlinked, or attached from a deferred commit, and the event locale is the request locale's primary tag.
- A widget action dispatched while the widget connects is delivered.
- Uploading through a form extension follows that extension's visibility.
- A required field that belongs to a form extension is checked on the extension's own value, so saving the host form no longer fails on a rule the owner model cannot satisfy.
- The additive API check compares the new release with the previous one after the tree is tagged.

View File

@@ -0,0 +1,70 @@
tag-and-publish
framework yes
v0.1.1
v0.1.10
v0.1.11
v0.1.12
v0.1.13
v0.1.14
v0.1.15
v0.1.16
v0.1.17
v0.1.18
v0.1.19
v0.1.2
v0.1.20
v0.1.21
v0.1.22
v0.1.23
v0.1.24
v0.1.25
v0.1.26
v0.1.27
v0.1.3
v0.1.4
v0.1.5
v0.1.6
v0.1.7
v0.1.8
v0.1.9
v0.2.0
v0.2.1
v0.2.2
v0.2.3
v0.2.4
v0.3.0
v0.3.1
v0.4.0
v0.4.1
v0.4.2
v0.4.3
v0.4.4
v0.5.0
v0.5.1
.
user yes
v0.1.0
v0.1.1
v0.1.10
v0.1.11
v0.1.12
v0.1.13
v0.1.14
v0.1.15
v0.1.2
v0.1.3
v0.1.4
v0.1.5
v0.1.6
v0.1.7
v0.1.8
v0.1.9
.
oauth yes
v0.1.0
v0.1.1
v0.1.2
v0.1.3
.
layer no
.

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,53 @@
---
phase: 32.1
review: 32.1-REVIEW.md
titles: json
findings:
- id: CR-01
severity: critical
disposition: fixed
title: "An in-flight MCP allow recreates a consent the user just revoked"
- id: WR-01
severity: warning
disposition: open
title: "Clearing a declared scope does not stop `read` or `write` from implying it"
- id: WR-02
severity: warning
disposition: open
title: "`mcp_scopes` does not limit what an OAuth `read` or `write` grant can call"
- id: WR-03
severity: warning
disposition: open
title: "The consent backfill treats unusable tokens as live grants"
- id: WR-04
severity: warning
disposition: open
title: "Replacing a consent does not revoke refresh tokens that still carry the removed scopes"
- id: WR-05
severity: warning
disposition: open
title: "sm-user-plugin v0.1.16 does not require the framework version it uses"
- id: WR-06
severity: warning
disposition: open
title: "`/connect` can allow a stale request after `?request=` changes"
open: 6
total: 7
recorded: 2026-10-28T16:03:08.042Z
---
# Phase 32.1: Code Review Disposition
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| CR-01 | critical | fixed | sm-oauthserver-plugin 21f5985 (unreleased; ships with the next plugin tag) |
| WR-01 | warning | open | - |
| WR-02 | warning | open | - |
| WR-03 | warning | open | - |
| WR-04 | warning | open | - |
| WR-05 | warning | open | - |
| WR-06 | warning | 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,185 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
reviewed: 2026-10-28T15:58:24Z
depth: standard
files_reviewed: 53
files_reviewed_list:
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/pact/mcp_call_test.go
- modules/pact/mcp_call_negative_test.go
- modules/pact/README.md
- modules/pier/dispatch.go
- modules/pier/prm.go
- modules/pier/session.go
- modules/pier/stateless.go
- modules/pier/mcp_seam_test.go
- modules/pier/dispatch_mcpcall_test.go
- modules/pier/prm_scopes_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/authentication.md
- docs/services/mcp.md
- docs/services/oauth-server.md
- 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/mcp_implies_test.go
- sm-user-plugin/README.md
- sm-oauthserver-plugin/classes/mcp_scopes.go
- sm-oauthserver-plugin/classes/mcp_verifier.go
- sm-oauthserver-plugin/classes/mcp_backend_binding.go
- sm-oauthserver-plugin/classes/mcp_store.go
- sm-oauthserver-plugin/controllers/mcp_consent_api.go
- sm-oauthserver-plugin/controllers/authorizations.go
- sm-oauthserver-plugin/config/config.yaml
- sm-oauthserver-plugin/plugin.go
- sm-oauthserver-plugin/servers.go
- sm-oauthserver-plugin/lang/en/lang.yaml
- sm-oauthserver-plugin/lang/pl/lang.yaml
- sm-oauthserver-plugin/README.md
- sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go
- sm-oauthserver-plugin/mcp_scopes_test.go
- sm-oauthserver-plugin/mcp_consent_test.go
- sm-oauthserver-plugin/mcp_consent_smoke_test.go
- sm-oauthserver-plugin/mcp_backfill_test.go
- sm-oauthserver-plugin/authorizations_smoke_test.go
- sm-oauthserver-plugin/authorizations_kind_test.go
- sm-oauthserver-plugin/go.mod
- vue-oauth-layer/app/pages/connect.vue
- vue-oauth-layer/app/utils/connectConsent.ts
- vue-oauth-layer/tests/connect.test.ts
- vue-oauth-layer/nuxt.config.ts
- vue-oauth-layer/package.json
- vue-oauth-layer/README.md
findings:
critical: 1
warning: 6
info: 0
total: 7
status: issues_found
---
# Phase 32.1: Code Review Report
**Reviewed:** 2026-10-28T15:58:24Z
**Depth:** standard
**Files Reviewed:** 53
**Status:** issues_found
## Summary
Reviewed the phase 32.1 commits only: summercms.go `63d05e8`, `9e29d6f`, `876f0f0`, `9094bf9`; sm-user-plugin `25fd981`, `193a381`, `756797c`; sm-oauthserver-plugin `644641a`, `52529c6`, `a409074`, `4f8618e`, `a7743e7`; vue-oauth-layer `02c466e`, `37c1e12`, `7f805b3`. Lockfiles, the vue skeleton, and phase 33 planning commits were not treated as product changes. Advisory only; no product file was modified.
The scope list, `MCPCall` placement, PRM copy, and the OAuth-only `Implies` expansion match the phase decisions. The consent decision does not. `IssueCode` commits the authorization code, then a later transaction writes the consent row, and neither takes `LockUserGrants`. A revoke that lands in that gap deletes the code and the old consent, and the allow puts the consent back. Separately, clearing a declared scope on `/connect` does not stop the user guard from implying it back from locked `read`/`write`, and a replaced consent does not revoke refresh tokens that still carry the removed scopes.
## Narrative Findings (AI reviewer)
## Critical Issues
### CR-01: An in-flight MCP allow recreates a consent the user just revoked
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:221-241`, `sm-oauthserver-plugin/controllers/authorizations.go:138-164`, `sm-oauthserver-plugin/classes/mcp_store.go:256-279`
**Issue:** `MCPConsentDecide` calls `srv.IssueCode`, which commits `MarkIssued` in its own transaction, and only then calls `recordMCPConsent`. `MarkIssued` does not take `LockUserGrants`. Revoke does: it locks, deletes the consent, and deletes MCP codes, access tokens, and refresh tokens for that user and client.
This interleaving is possible whenever a consent row already exists (a re-consent, including the silent `consent_required: false` path):
1. Allow's `IssueCode` commits. The new MCP code exists. The consent row is still the old one, or not yet replaced.
2. Revoke commits. It deletes that consent and the new code, then returns `{"revoked": true}`.
3. Allow's `recordMCPConsent` runs. `Take` now returns not found, so `replaceMCPConsent` inserts a new row.
4. Allow returns 200 and the redirect for a code revoke already deleted.
The token exchange fails, but the consent row is back. The next authorize for scopes that row covers sets `consent_required` false, and `vue-oauth-layer` posts allow immediately. Revoke did not stick. The same split leaves a live code and a 500 if the consent write fails after `IssueCode` succeeds, because the redirect is withheld while the code hash is already stored.
**Fix:** Issue the code and upsert the consent in one transaction that holds `LockUserGrants` for the whole unit, the same exclusive lock revoke takes. Revoke then either waits and deletes both rows, or commits first and the allow sees that result inside the lock. Do not write the consent after `IssueCode` returns.
```go
err = s.backend.WithinTx(ctx, func(tx Tx) error {
if err := classes.LockUserGrants(ctx, tx.DB(), []uint{userID}); err != nil {
return err
}
if err := tx.MarkIssued(ctx, pending.ID, sha256Hex(code), userID, grantedScopes, collectionIDs, expiresAt); err != nil {
return err
}
if err := tx.MarkConsented(ctx, client.ClientID); err != nil {
return err
}
return replaceMCPConsent(ctx, tx.DB(), userID, client.ClientID, stored, now)
})
```
`replaceMCPConsent` has to use that same `tx`. A consent failure must roll the code back.
## Warnings
### WR-01: Clearing a declared scope does not stop `read` or `write` from implying it
**File:** `sm-user-plugin/classes/mcp_guard.go:160-174`, `sm-oauthserver-plugin/controllers/mcp_consent_api.go:408-418`, `vue-oauth-layer/app/pages/connect.vue:147-160`
**Issue:** `read` is locked whenever it was requested. Declared scopes are checkboxes the user can clear, and `grantedScopes` then omits them from the token and, on a real replace, from the consent row. `mcpEffectiveScopes` adds every catalog name whose `Implies` is `read` or `write` whenever the OAuth grant still holds that stock scope. A grant of only `read` is also what a client receives when the user cleared `servers:read` and left locked `read`. `MCPAllows` and the in-memory `user.scope` credential both see the name again, so the tool still runs. The checkbox is not a denial for any scope implied by a stock scope that remains on the grant.
**Fix:** Store an explicit denial, or stop presenting an implied name as optional while its stock scope is on the request. If the name stays optional, `mcpEffectiveScopes` must not append a declared name the user cleared on this grant. Absence cannot mean both "never requested" and "user denied" without a separate denial list.
### WR-02: `mcp_scopes` does not limit what an OAuth `read` or `write` grant can call
**File:** `sm-user-plugin/classes/mcp_guard.go:165-173`, `sm-oauthserver-plugin/classes/mcp_scopes.go:25-57`, `sm-oauthserver-plugin/config/config.yaml:77-84`
**Issue:** `AdvertisedMCPScopes` drops declared names that are not in `golem15.oauthserver.mcp_scopes`. An empty list is documented as the five fixed names only, and authorize rejects anything outside the advertised slice. `mcpEffectiveScopes` never reads that slice. It walks the whole member-token catalog. A client that was only allowed to request `read` still satisfies every `Implies: "read"` name, including names the operator excluded and that `/connect` never shows.
**Fix:** Intersect the expansion with the advertised MCP scope list (the same slice `NewServers` stored on the verifier). An unset filter still implies every declared name. A set or empty filter then matches what metadata, authorize, and the consent page show.
### WR-03: The consent backfill treats unusable tokens as live grants
**File:** `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go:151-182`
**Issue:** `loadLiveMCPTokens` keeps a row when `revoked_at` is null and `expires_at` is null or in the future. It does not require an active, unrevoked MCP client, a non-null access-token expiry, or `rotated_to_id IS NULL`. `MCPTokenVerifier` rejects an access token with a nil expiry, a revoked client, or an inactive client (`classes/mcp_verifier.go:103-115`). A refresh token with `rotated_to_id` set is a replay, not a usable grant (`modules/wristband/token.go:332-340`). Those rows still seed `golem15_oauthserver_consents`. The inserted consent outlives the token, so a later authorize for those scopes skips the prompt even though this server would not have honored the source token.
**Fix:** Match the verifier. Require `c.revoked_at IS NULL AND c.is_active`, `a.expires_at IS NOT NULL AND a.expires_at > NOW()` for access tokens, and `r.rotated_to_id IS NULL` for refresh tokens.
### WR-04: Replacing a consent does not revoke refresh tokens that still carry the removed scopes
**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:348-364`, `modules/wristband/token.go:361-370`
**Issue:** A real allow replaces `golem15_oauthserver_consents.scopes` with the new grant. It does not revoke that client's access or refresh tokens. Refresh rotation copies `rec.Scopes` and `rec.OfflineAccess` from the existing refresh row, not from the consent row. After the user clears `write` or `offline_access` on a later allow, the old refresh token keeps minting access tokens with the old scope set until it expires or the user deletes the whole authorization.
**Fix:** In the same locked transaction as the consent replace, revoke this client’s access tokens and refresh-token lineages whose scopes are not a subset of the new grant (and whose `offline_access` column is set when the new grant omits it). Leave other clients alone.
### WR-05: sm-user-plugin v0.1.16 does not require the framework version it uses
**File:** `sm-user-plugin/go.mod:6`, `sm-user-plugin/go.mod:183`
**Issue:** The tagged module calls `pact.MemberTokenScopeCatalog` and `FrontendTokenScope.Implies`, which shipped in summercms v0.5.2. `go.mod` still requires `git.golem15.com/golem15/summercms v0.5.0` and replaces it with `../summercms.go`. Downstream modules ignore that replace. An app that selects v0.1.16 without already selecting summercms v0.5.2 does not compile. sm-oauthserver-plugin v0.1.4 happens to require v0.5.2, so the full stack builds only because of that other module.
**Fix:** Require `git.golem15.com/golem15/summercms v0.5.2` in sm-user-plugin and drop the sibling replace from the release commit. A local replace can stay uncommitted in a development checkout.
### WR-06: `/connect` can allow a stale request after `?request=` changes
**File:** `vue-oauth-layer/app/pages/connect.vue:52-68`
**Issue:** `load` has no generation token. `watch(requestId)` starts another `load` while the previous `$fetch` is in flight, and `consent_required === false` posts allow before render. Whichever response arrives last assigns `show` and, if that body does not require consent, calls `submit('allow')` for the request it loaded, which may no longer be `requestId`. Both requests can be approved, and the browser can be sent to the redirect of the one the user is no longer on.
**Fix:** Capture a sequence number (or the request id) before the fetch and ignore the response, including the auto-allow, when it is no longer current.
```ts
let loadSeq = 0
async function load(fromRefetch = false) {
const seq = ++loadSeq
const id = requestId.value
// ...
const body = await $fetch<ConsentShow>(consentUrl(), { credentials: 'include' })
if (seq !== loadSeq || id !== requestId.value) return
// assign show and maybe submit
}
```
---
_Reviewed: 2026-10-28T15:58:24Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,100 @@
---
phase: "32.1"
slug: "mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con"
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-11-05"
---
# Phase 32.1 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. Evidence paths: F = `summercms.go`, U = `sm-user-plugin`, O = `sm-oauthserver-plugin`, V = `vue-oauth-layer`, S = `sm-summercmsio-app` (all under `../`). Baseline for "unchanged" claims: `2cea312`, the commit before the phase.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| MCP client to wristband authorize | Untrusted scope string and redirect cross here; the phase only changes which names are legal | scope string, redirect URI, PKCE challenge (high) |
| pier dispatch to route handler | the handler gets an MCPCall built from the resolved subject, not from tool arguments | subject, client id, token id (high) |
| OAuth grant scopes to MCPAllows / user.scope | the grant is the consented set; Implies may add only names the plugin marked | granted scopes (high) |
| DELETE /oauth/authorizations/{client_id} | a signed-in user names a client; another user's rows must stay | client id, user session (high) |
| Operator config `mcp_scopes` | names enter metadata and the allow-list at boot | scope names (medium) |
| MCP consent JSON to the browser | client name, labels and admin name are attacker-influenced (DCR name, phrase text) | display strings (medium) |
| Page POST back to the consent route | the browser can omit fields; the server intersection is the control | decision, scopes, backend_admin (high) |
| Test output and changelog notes | tokens and authorization codes must not reach logs or the public post | test output, release notes (medium) |
| git push of a release tag | the tag is what other repositories require | tag (low) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-32.1-01 | Elevation of privilege | wristband resolvedScopes | high | mitigate | F/modules/wristband/server.go:203 `mcpEmptyScopes` (five names incl. backend); :210-218 never widens a non-empty list; used by metadata :194 and authorize.go:29. `DefaultOptions` and OIDC discovery unchanged | closed |
| T-32.1-02 | Elevation of privilege | pier filter.go read/write ceiling | high | mitigate | filter.go and identity.go unchanged since baseline; ceiling identity.go:80-82 `writes()` used at filter.go:66,:75; no `Implies` reference in pier, bouncer or wristband | closed |
| T-32.1-03 | Spoofing | wristband Authorize redirect | high | mitigate | 63d05e8 touched only `allowedAuthorizeScopes`; authorize.go order: client lookup :62-82, exact redirect :84-88, PKCE :95-108, scope parse :110 | closed |
| T-32.1-04 | Information disclosure | pact.MCPCall.Subject.TokenID | medium | mitigate | TokenID rule in pact/README.md:170, pier/README.md:13 and docs; dispatch.go:211-215 adds no log line; subject from `id.subjectFor` :172; audit.go unchanged | closed |
| T-32.1-05 | Elevation of privilege | MCPUserGuard Implies helper | high | mitigate | U/classes/mcp_guard.go:160-175 `mcpEffectiveScopes`: personal tokens unchanged, clone only, read/write Implies only, exact stock scope required, no write-back; both call sites (:94, :145); `CompileAPITokenScopes` rejects backend/offline_access and bad Implies (api_token_scopes.go:139,:148). Residual: WR-01, WR-02 | closed |
| T-32.1-06 | Elevation of privilege | MCP consent upsert | high | mitigate | O/controllers/mcp_consent_api.go: deny → `DenyPending` (:180-183); silent re-grant → `TouchConsent` (:355-358); decision → `replaceMCPConsent` ON CONFLICT sets scopes (:382-389), replace not union. Test `TestMCPConsentReplace`. Residual: WR-03, WR-04 (CR-01 fixed in 21f5985) | closed |
| T-32.1-07 | Elevation of privilege | authorizations.revoke | high | mitigate | O/controllers/authorizations.go:137-163: `LockUserGrants` advisory lock in-tx before deletes of consent, codes and tokens; `MCPSubjectRevoked` after commit. Test `TestMCPRevokeDeletesCode`. CR-01 fixed in 21f5985: the MCP allow takes the same lock | closed |
| T-32.1-08 | Information disclosure | PendingRequest / IssueCode | high | mitigate | F/modules/wristband/consent.go:139-174 `lookupOwnedPending` unchanged, all misses → `ErrPendingNotFound` → 404. Plugin's `mcpPendingClientID` (mcp_consent_api.go:322-332) looks up by request id alone but both callers run it after the owner-checked `PendingRequest` | closed |
| T-32.1-09 | Tampering | grantedScopes when scopes is absent | medium | mitigate | mcp_consent_api.go:247-250 absent scopes = requested set; offline override only when present (:224-226); request struct takes decision, scopes, backend_admin only | closed |
| T-32.1-10 | Information disclosure | consent and revoke logs | medium | mitigate | phase diff `df2eddd..a7743e7` adds no log line in plugin non-test code; error-code sets unchanged | closed |
| T-32.1-11 | Tampering | app/pages/connect.vue | high | mitigate | no `v-html`/innerHTML in V/app/; connect.vue uses text interpolation and attribute bindings; V/tests/connect.test.ts:107-109 asserts no `v-html`. Residual: WR-06 | closed |
| T-32.1-12 | Elevation of privilege | read checkbox | low | accept | AR-32.1-01 | closed |
| T-32.1-13 | Information disclosure | tests and changelog-post.py | medium | mitigate | phase test messages print only codes, error keys, scope lists and ids; notes contain no token prefixes; S/scripts/changelog-post.py:407-411 exits without token, :318-326 refuses a world-readable token file | closed |
| T-32.1-14 | Spoofing | release tags | low | accept | AR-32.1-02 | 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-32.1-01 | T-32.1-12 | The read checkbox is disabled and the page's own POST includes read. A client that omits read only narrows the grant; the server must not reinsert read because the 422 smoke posts backend alone. Narrowing is not a privilege gain. `grantedScopes` does not special-case read. | 32.1-04-PLAN | 2026-11-05 |
| AR-32.1-02 | T-32.1-14 | Tagging does not change PKCE, redirect matching or the read/write ceiling; the checkpoint is the control on publishing, not a new auth path. | 32.1-05-PLAN | 2026-11-05 |
*Accepted risks do not resurface in future audit runs.*
---
## Residual Risk (open review findings, outside the L1 register)
`32.1-REVIEW-DISPOSITION.md` lists these as `open`. The auditor confirmed each is still present in the code on 2026-11-05. They do not count toward `threats_open` at ASVS L1, but CR-01 would make T-32.1-07 a blocking open threat at L2. CR-01 has since been fixed (sm-oauthserver-plugin 21f5985).
| Finding | Severity | Touches | Gap |
|---------|----------|---------|-----|
| CR-01 | critical | T-32.1-07, T-32.1-06 | Fixed in sm-oauthserver-plugin 21f5985 (2026-11-05, unreleased): `MCPConsentDecide` issues the code and writes the consent in one transaction under `classes.WithinUserGrantsLock` (exclusive `LockUserGrants`, the lock revoke takes); a failed consent write rolls the code back. Tests `TestMCPConsentAllowSerializesWithRevoke`, `TestMCPConsentFailureRollsBackCode`. |
| CR-01 sibling (OIDC) | high | T-32.1-07 | Fixed in sm-oauthserver-plugin 7b9899f (2026-11-05, unreleased): the OIDC decide was already consistent (consent upsert and code in one transaction, ordered against revoke by the consent row lock; pinned by `TestOIDCConsentAllowThenRevoke`), but a silent resume could issue a code after a revoke, a refresh rotation's successor tokens survived a revoke and a code exchange could deadlock with one. The OIDC store now takes the user's grants lock (shared) before its first grant row. Tests `TestOIDCResumeSerializesWithRevoke`, `TestOIDCRefreshSerializesWithRevoke`, `TestOIDCExchangeSerializesWithRevoke`. |
| WR-01, WR-02 | warning | T-32.1-05 | `mcpEffectiveScopes` re-implies a declared scope the user cleared on /connect while read/write remains, and does not intersect the catalog with `golem15.oauthserver.mcp_scopes`. |
| WR-04 | warning | T-32.1-06 | narrowing a consent does not revoke refresh tokens carrying the removed scopes or offline_access. |
| WR-03 | warning | T-32.1-06 | the consent backfill seeds from tokens the verifier would refuse (nil-expiry, inactive/revoked clients, rotated refresh tokens). |
| WR-06 | warning | T-32.1-11 | /connect `load()` has no generation guard; a stale `consent_required:false` can auto-allow a request the user no longer views. |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-11-05 | 14 | 14 | 0 | msd-security-auditor (ASVS L1, block_on high) |
---
## 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
- [x] CR-01 given a disposition in 32.1-REVIEW-DISPOSITION.md (fixed: sm-oauthserver-plugin 21f5985)
**Approval:** verified 2026-11-05 (ASVS L1; CR-01 fixed in sm-oauthserver-plugin 21f5985, unreleased)

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,195 @@
---
phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con
verified: 2026-10-28T15:54:38Z
status: passed
score: 8/8 must-haves verified
covered_files:
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md"
- ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md"
- "docs/services/authentication.md"
- "docs/services/mcp.md"
- "docs/services/oauth-server.md"
- "modules/pact/README.md"
- "modules/pact/capabilities.go"
- "modules/pact/mcp.go"
- "modules/pact/mcp_call_negative_test.go"
- "modules/pact/mcp_call_test.go"
- "modules/pier/README.md"
- "modules/pier/dispatch.go"
- "modules/pier/dispatch_mcpcall_test.go"
- "modules/pier/events.go"
- "modules/pier/filter.go"
- "modules/pier/identity.go"
- "modules/pier/mcp_seam_test.go"
- "modules/pier/prm.go"
- "modules/pier/prm_scopes_test.go"
- "modules/pier/session.go"
- "modules/pier/stateless.go"
- "modules/wristband/README.md"
- "modules/wristband/authorize.go"
- "modules/wristband/consent.go"
- "modules/wristband/mcp_scopes_smoke_test.go"
- "modules/wristband/server.go"
covered_digest: "v2:sha256:647c82bb902ca0f7439d83e8c93465a4d4a63e02d02ec0ed4838ce50b4489262"
behavior_unverified: 0
overrides_applied: 0
decision_coverage:
honored: 18
total: 18
not_honored: []
---
# Phase 32.1: MCP OAuth for plugin apps Verification Report
**Phase 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. Three gaps ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`.
**Verified:** 2026-10-28T15:54:38Z
**Status:** passed
**Re-verification:** No — initial verification
MVP mode does not apply. Phase 32.1 has no `Mode: mvp` in ROADMAP.md. The goal is user-story shaped but does not match the validator (`I want an agent`, not `I want to`).
Sibling implementations were read and tested in their own checkouts. They cannot be listed in `covered_files` because the fingerprint refuses paths outside this repository. The released commits are `summercms` `v0.5.2` (`9094bf9`, tag object `35c1f0f`), `sm-user-plugin` `v0.1.16` (`756797c`), `sm-oauthserver-plugin` `v0.1.4` (`a7743e7`), and local `vue-oauth-layer` `v0.1.0` (`7f805b3`). Framework `HEAD` (`bba6539`) is only the phase-05 planning commit; `v0.5.2..HEAD` does not change product code.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| --- | ------- | ---------- | ---------- |
| 1 | Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth. Wristband metadata and the authorize allow-list, and pier protected-resource scopes, include them, so a tool behind `user.scope:<declared>` lists and runs for an OAuth agent granted that scope (including a stock scope the declaration `Implies`). | ✓ VERIFIED | `AdvertisedMCPScopes` appends declared catalog names after the five fixed names. `NewServers` assigns that one slice to `mcpOpts.ScopesSupported` and `NewMCPTokenVerifier`. Metadata and `allowedAuthorizeScopes` both call `resolvedScopes`. pier copies `MCPScopesSupported` when non-empty. `MCPAllows` / `MCPDispatch` share `mcpEffectiveScopes`, which keeps names already on the grant and, only when `ClientID` is set, appends catalog names whose `Implies` is held. `tools/list` reaches that check through `filter.go` `allows`. `user.scope` reads the dispatch credential via `HasScope`. `TestMCPAdvertisedScopes` (metadata equals the verifier, including `acme:read`) and `TestMCPImplies*` passed. |
| 2 | A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context. A personal-token call's credential keeps the token id. | ✓ VERIFIED | `dispatch.go` sets `WithMCPCall` with `id.subjectFor` immediately before `bouncer.WithDispatch`. OAuth identity copies `ClientID` and `TokenID` from the grant onto both frontend and backend subjects. `MCPCallFrom` on a bare context is false. `MCPDispatch` sets `credential.ID = subject.TokenID` only when `ClientID` is empty. `TestMCPCallFromBareContext`, `TestMCPCallRoundTrip`, `TestDispatchMCPCallBothGuards`, and `TestMCPImpliesPersonalUnchanged` passed. |
| 3 | `sm-oauthserver-plugin` records a consent for MCP grants, so per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents. | ✓ VERIFIED | `MCPConsentDecide` calls `recordMCPConsent` after `IssueCode`. `replaceMCPConsent` is `ON CONFLICT` assignment, not `SaveConsent`. `revoke` takes `LockUserGrants` and deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens for that user and client, then fires `MCPSubjectRevoked` with `ClientID` and a zero `TokenID`. pier `revokedMatch` closes only that client's sessions when `TokenID` is 0. `TestMCPConsentReplace`, `TestMCPRevokeDeletesCode` (other client's token still verifies; event shape matches), and `TestMCPConsentBackfillIdempotent` passed. Rollback returns nil and does not delete. |
| 4 | Module READMEs and `docs/` pages describe the three changes. The framework and `sm-oauthserver-plugin` have patch tags. | ✓ VERIFIED | pact, pier, wristband, user-plugin and oauthserver READMEs plus `docs/services/mcp.md`, `oauth-server.md` and `authentication.md` name `MCPCallFrom`, `MCPAdvertisedScopes`, `MemberTokenScopeCatalog`, `Implies`, `mcp_scopes`, consent replace and the per-client revoke. No consuming application is named. `go test ./cmd/summer -run TestDocsTree` passed. Remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Changelog `https://summercms.io/changelog/summercms-0-5-2` returned HTTP 200 with title `SummerCMS 0.5.2`. |
| 5 | An empty MCP `Options.ScopesSupported` 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 stays `openid`, `profile`, `email`. `PendingRequestView.OfflineAccess` is the pending flag. `IssueCode`'s signature is unchanged. | ✓ VERIFIED | `resolvedScopes` in `modules/wristband/server.go`. `PendingRequest` copies `pending.OfflineAccess`. `IssueCode(ctx, requestID, userID, grantedScopes, collectionIDs)`. `TestMCPScopesSmoke`, `TestDiscoveryFields` and `TestMCPOfflineAccessUntick` passed. |
| 6 | Implies expansion is OAuth-only. `write` does not satisfy `Implies` `read`. The compiled catalog is published as `pact.MemberTokenScopeCatalog`. Implied names are not written onto the subject, the token row, or a fingerprint. The demo app user submodule was not edited. | ✓ VERIFIED | `bootAPITokenScopes` publishes the concrete catalog and `pact.MemberTokenScopeCatalog`. `mcpEffectiveScopes` returns `subject.Scopes` unchanged when `ClientID` is empty and never writes the clone back. `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesNotPersisted`, `TestMCPImpliesAlreadyPresent` and `TestMCPImpliesCompileRejects` passed. `sm-demo-app/plugins/golem15/user` is still tag `v0.1.13`. |
| 7 | `golem15.oauthserver.mcp_scopes` unset advertises every declared member scope; a set list keeps only those names; an empty list is the five fixed names; an unknown name fails boot and names the key. Fixed names are always present. pier copies the verifier list or `PRMScopes` and does not read the member-token catalog. | ✓ VERIFIED | `classes/mcp_scopes.go` and `servers.go`. `mountPRM` type-asserts `MCPAdvertisedScopes` only. `TestMCPAdvertisedScopes`, `TestPRMUsesAdvertisedScopes` and `TestPRMFallsBackToPRMScopes` passed. |
| 8 | Authorization entries carry `kind` `oidc` or `mcp`. An allow with no scopes field grants the whole request. `offline_access` stays a column flag. The `/connect` layer locks `read` in the UI, does not ask the server to force `read`, renders client and scope text rather than raw HTML, and refetches on `409 backend_link_changed`. | ✓ VERIFIED | `TestAuthorizationsKind` and `TestMCPOfflineAccessUntick` passed. `grantedScopes` returns the request when `submitted` is nil. `allowBody` keeps locked names and omits `scopes` when consent is already stored. `connect.vue` interpolates `{{ }}` and calls `shouldRefetch` then `load(true)`. `node --test tests/connect.test.ts` passed (8 tests), including the source scan that rejects `v-html`. |
**Score:** 8/8 truths verified (0 present, behavior-unverified)
### Decision Coverage
All trackable CONTEXT.md decisions are honored by shipped artifacts (18/18). Non-blocking.
### Required Artifacts
| Artifact | Expected | Status | Details |
| -------- | ----------- | ------ | ------- |
| `modules/pact/mcp.go` | Catalog, advertised scopes, `MCPCall` / `WithMCPCall` / `MCPCallFrom` | ✓ VERIFIED | Interfaces and accessor are exported and used by pier and both plugins. On `v0.5.2`. Absent from `v0.5.0`. |
| `modules/pact/capabilities.go` | `FrontendTokenScope.Implies` | ✓ VERIFIED | Field present. User plugin rejects any value other than empty, `read`, or `write`. |
| `modules/pier/dispatch.go` | `MCPCall` beside `WithDispatch` | ✓ VERIFIED | Lines 215–216. Both guards' tool calls observed it. |
| `modules/pier/prm.go` | PRM scopes from the verifier, else `PRMScopes` | ✓ VERIFIED | Does not look up `MemberTokenScopeCatalog`. |
| `modules/wristband/server.go` | One `resolvedScopes` slice | ✓ VERIFIED | Metadata uses it. Authorize builds its allow-list from the same method. |
| `modules/wristband/consent.go` | `PendingRequestView.OfflineAccess`; `IssueCode` signature unchanged | ✓ VERIFIED | Direct copy from the pending row. |
| `sm-user-plugin/classes/mcp_guard.go` | OAuth-only Implies shared by `MCPAllows` and `MCPDispatch` | ✓ VERIFIED | On tag `v0.1.16`. |
| `sm-user-plugin/plugin.go` | Publish `MemberTokenScopeCatalog` | ✓ VERIFIED | `bootAPITokenScopes`. |
| `sm-oauthserver-plugin/classes/mcp_scopes.go` | `golem15.oauthserver.mcp_scopes` | ✓ VERIFIED | Key documented in `config/config.yaml` (commented default). |
| `sm-oauthserver-plugin/classes/mcp_verifier.go` | `MCPScopesSupported` | ✓ VERIFIED | Same slice stored at `NewServers`. |
| `sm-oauthserver-plugin/controllers/mcp_consent_api.go` | `scope_details` and consent replace | ✓ VERIFIED | `replaceMCPConsent` uses `ON CONFLICT`. |
| `sm-oauthserver-plugin/controllers/authorizations.go` | Revoke under `lockUserGrants`, then the client-scoped event | ✓ VERIFIED | Deletes `MCPCode` with the other pair rows. |
| `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` | Idempotent backfill, no-delete rollback | ✓ VERIFIED | `WHERE NOT EXISTS`. `Rollback` returns nil. |
| `vue-oauth-layer/app/pages/connect.vue` | `/connect` checkboxes | ✓ VERIFIED | Imports `allowBody`. Text interpolations only. Local tag `v0.1.0`. |
| `vue-oauth-layer/app/utils/connectConsent.ts` | Show JSON to POST body | ✓ VERIFIED | Locked `read` stays. No server-side force-read field. |
### Key Link Verification
| From | To | Via | Status | Details |
| ---- | --- | --- | ------ | ------- |
| `wristband.Server.Metadata` | `allowedAuthorizeScopes` | `resolvedScopes` | ✓ WIRED | Both call the same method. A non-empty host list is cloned as given. |
| `NewServers` | `MCPTokenVerifier.MCPScopesSupported` | the slice assigned to `mcpOpts.ScopesSupported` | ✓ WIRED | `TestMCPAdvertisedScopes` compared metadata JSON to the verifier. |
| `pier.mountPRM` | `pact.MCPAdvertisedScopes` | lookup of `MCPTokenVerifier`, then type assert | ✓ WIRED | Empty or unimplemented falls back to `PRMScopes`. |
| `pier.dispatch` | `pact.MCPCallFrom` | `WithMCPCall` immediately before `WithDispatch` | ✓ WIRED | Frontend and backend tool handlers observed the call. |
| `MCPAllows` | `MCPDispatch` | `mcpEffectiveScopes` | ✓ WIRED | One helper. Personal tokens return the same slice. |
| `plugin.bootAPITokenScopes` | `pact.MemberTokenScopeCatalog` | `app.Publish` | ✓ WIRED | `TestMCPImpliesOAuth` looks the interface up. |
| `MCPConsentDecide` | `golem15_oauthserver_consents` | replace upsert after `IssueCode`, not `SaveConsent` | ✓ WIRED | Second allow of a smaller set stored only `read`. |
| `authorizations.revoke` | MCP sessions | `LockUserGrants`, delete `MCPCode`, `MCPSubjectRevoked` with `ClientID` | ✓ WIRED | pier `revokedMatch` selects that client when `TokenID` is 0. |
| `connect.vue` | `POST /oauth/mcp/requests/{request}` | `allowBody` (`decision`, `scopes`, `backend_admin`) | ✓ WIRED | Page and tests import the same functions. |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
| -------- | ------------- | ------ | ------------------ | ------ |
| Wristband MCP metadata `scopes_supported` | `resolvedScopes()` | `Options.ScopesSupported` set from `AdvertisedMCPScopes` (catalog plus fixed names) | Yes | ✓ FLOWING |
| pier protected-resource `scopes_supported` | `advertised.MCPScopesSupported()` | the verifier slice, else `PRMScopes` | Yes | ✓ FLOWING |
| MCP consent row `scopes` | `stored` in `MCPConsentDecide` | granted intersection of the pending request | Yes | ✓ FLOWING |
| Route `pact.MCPCall.Subject` | `id.subjectFor` | OAuth grant or personal-token resolver (`ClientID`, `TokenID`, scopes) | Yes | ✓ FLOWING |
| `user.scope` check during a tool call | dispatch `*models.APIToken.Scopes` | `mcpEffectiveScopes` (grant, plus OAuth implied names) | Yes | ✓ FLOWING |
| `/connect` checkboxes | `scope_details` from consent show | catalog labels, not a hardcoded tool list | Yes | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| -------- | ------- | ------ | ------ |
| Bare context has no MCP call; round trip keeps the subject | `go test -short -count=1 ./modules/pact -run 'TestMCPCallFromBareContext\|TestMCPCallRoundTrip\|TestFrontendTokenScopeLiteralWithoutImplies'` | ok | ✓ PASS |
| Both guards record `MCPCall`; PRM uses the advertised list or falls back | `go test -short -count=1 ./modules/pier -run 'TestDispatchMCPCallBothGuards\|TestPRMUsesAdvertisedScopes\|TestPRMFallsBackToPRMScopes\|TestMCPSeam'` | ok | ✓ PASS |
| Empty MCP scope list includes `backend`; OIDC discovery stays the three names | `go test -short -count=1 ./modules/wristband -run 'TestMCPScopesSmoke\|TestDiscoveryFields'` | ok | ✓ PASS |
| OAuth Implies, personal token unchanged, write does not grant read | `go test -short -count=1 . -run TestMCPImplies` in `sm-user-plugin` | ok | ✓ PASS |
| Declared name `acme:read` is on MCP metadata and the verifier; unknown `mcp_scopes` fails boot | `GOWORK=off` not required; `go test -short -count=1 . -run TestMCPAdvertisedScopes` in `sm-oauthserver-plugin` | ok | ✓ PASS |
| Consent replace, offline flag, revoke leaves the other client, kind, idempotent backfill | `GOWORK=off go test -count=1 -timeout 180s -run 'TestMCPConsentReplace\|TestMCPRevokeDeletesCode\|TestMCPConsentBackfillIdempotent\|TestAuthorizationsKind\|TestMCPOfflineAccessUntick'` | ok (6.951s) | ✓ PASS |
| `/connect` mapping, locked read, no `v-html` | `node --test tests/connect.test.ts` | 8 pass | ✓ PASS |
| Docs identifiers | `go test ./cmd/summer -count=1 -run TestDocsTree` | ok | ✓ PASS |
| Patch tags on the remotes | `git ls-remote --tags` for `v0.5.2`, `v0.1.16`, `v0.1.4` | each tag object matches the local tag | ✓ PASS |
| Changelog post | `curl -fsS https://summercms.io/changelog/summercms-0-5-2` | HTTP 200, title `SummerCMS 0.5.2` | ✓ PASS |
`TestMCPAdvertisedScopes` was run in the oauth plugin directory, whose uncommitted `go.work` points `sm-user-plugin` at `sm-demo-app` `v0.1.13`. That test publishes its own catalog and does not import the phase user plugin. The consent tests were run with `GOWORK=off`, so they used `go.mod`'s `sm-user-plugin v0.1.16`.
### Probe Execution
No phase plan or success criterion declares a `scripts/*/tests/probe-*.sh` probe. None were required.
### Test Quality Audit
| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict |
|-----------|-----------|--------|---------|----------|-----------------|---------|
| `modules/pact/mcp_call_negative_test.go` | SC-2 | 2 | 0 | no | value (`ok` false; empty `Implies`) | sufficient |
| `modules/pier/dispatch_mcpcall_test.go` | SC-2 | 1 | 0 | no | behavioral (real `tools/call` on both guards) | sufficient |
| `modules/pier/prm_scopes_test.go` | SC-1 | 2 | 0 | no | value (scope list includes `acme:read`, fallback includes `backend`) | sufficient |
| `sm-user-plugin/mcp_implies_test.go` | SC-1, SC-2 | 6 matched by `TestMCPImplies` | 0 | no | value (credential id, scopes, slice identity) | sufficient |
| `sm-oauthserver-plugin/mcp_consent_test.go` | SC-3 | replace, offline, revoke | 0 in the run | no | behavioral (stored row, other token still verifies, event equality) | sufficient |
| `sm-oauthserver-plugin/mcp_backfill_test.go` | SC-3 | idempotent backfill | 0 in the run | no | behavioral | sufficient |
| `vue-oauth-layer/tests/connect.test.ts` | layer must-haves | 8 | 0 | no | value, plus a source scan for `v-html` | sufficient |
**Disabled tests on requirements:** 0
**Circular patterns detected:** 0
**Insufficient assertions:** 0
`TestDispatchMCPCallBothGuards` checks guard, tool and session id. It does not echo `ClientID` or `TokenID`. Those fields are copied with the whole `MCPSubject` in `identity.go` (`ClientID: grant.ClientID`, `TokenID: grant.TokenID`) and stored by `WithMCPCall`. The personal-token id rule is asserted directly in `TestMCPImpliesPersonalUnchanged`.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ---------- | ----------- | ------ | -------- |
| none | all five plans `requirements: []` | ROADMAP says Requirements: TBD. `REQUIREMENTS.md` has no Phase 32.1 IDs. | ✓ SATISFIED | No ID to satisfy and none orphaned. |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
| ---- | ---- | ------- | -------- | ------ |
| `sm-user-plugin/go.mod` | require | `summercms v0.5.0` while the tagged source calls `pact.MemberTokenScopeCatalog`, which does not exist on `v0.5.0` | ⚠️ Warning | A module graph that selects only `v0.5.0` does not compile `v0.1.16`. `sm-oauthserver-plugin` `v0.1.4` requires `summercms v0.5.2` and `sm-user-plugin v0.1.16`, so the graph that turns MCP OAuth on selects `v0.5.2`. The local `replace` is ignored by dependents. Not a failed success criterion. |
| `vue-oauth-layer` | tag `v0.1.0` | No `origin`. README inside the tag still says the tag does not exist. | ⚠️ Warning | SC-4 does not require this tag. Apps cannot pin the layer until a remote exists. The local tag was not moved. |
| `sm-oauthserver-plugin/go.work` | uncommitted | `replace` of `sm-user-plugin` points at `sm-demo-app/plugins/golem15/user` (`v0.1.13`), not the phase branch | ℹ️ Info | Not on tag `v0.1.4`. Dependents do not use `go.work`. A local `go test` without `GOWORK=off` would compile the wrong user plugin. |
| `sm-user-plugin/classes/device_auth.go` | comments | The letters `XXXX` match a `XXX` debt scan | ℹ️ Info | Short-code format, not a `XXX` marker. No `TBD` / `FIXME` / `XXX` debt markers in the phase files. |
### Human Verification Required
None. The state transitions (Implies expansion, consent replace, revoke isolation, backfill idempotence, both guards' `MCPCall`) were exercised by the named tests above. The `/connect` contract is covered by `node --test`, including the no-raw-HTML source check. No live browser OAuth session was required: the phase ships the framework and plugins, and it does not turn `mcp.enabled` on in an application.
### Gaps Summary
No gap blocks the phase goal. Declared member scopes are on the MCP authorization server and the protected-resource metadata. An OAuth subject can list and run a `user.scope:<declared>` tool either because the grant holds that name or because the catalog marks it implied by `read` or `write`. The route can read the caller through `pact.MCPCallFrom`, and a personal token's credential id is still the token id. An MCP allow writes a consent row that a later smaller allow replaces, and deleting that authorization revokes only that client. Docs describe the three changes. `v0.5.2` and `sm-oauthserver-plugin` `v0.1.4` are on their remotes, and the framework changelog post is live.
The two warnings (user-plugin minimum version, unpublished vue layer) do not stop an application that takes `sm-oauthserver-plugin` `v0.1.4` from compiling the OAuth path. They should be fixed in a later patch if the user plugin is built on its own, or when `vue-oauth-layer` gets a remote.
---
_Verified: 2026-10-28T15:54:38Z_
_Verifier: Claude (msd-verifier)_

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,254 @@
---
phase: 33-seo-output-audit-and-content
plan: 01
subsystem: framework
tags: [pact, signpost, cabana, content-descriptor, field-types, gate-script, release-ledger]
requires:
- phase: 32-seo-foundation
provides: pact.ContentDescriptor, signpost content registry, plugin field types, form extensions, check-phase32.sh engine
provides:
- pact.ContentDescriptor.Kind and Tools with ContentKind*/ContentTool* constants
- pact.ContentRecord.PublishedAt, Parents, Authors, TopHeading
- pact.ContentLocale.Images, Videos, Headings and the value types ContentRef, ContentPerson, ContentImage, ContentVideo, ContentHeading
- pact.FormFieldType.RequestOptions merged into served typeOptions per schema request
- scripts/check-phase33.sh (eleven stages plus --all)
- 33-release-log.md (v0.6.1 planned contents and release order)
affects: [33-02, 33-03, 33-04, 33-05, 33-06, 33-07, 33-08, 33-09, 33-10, 33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 24700
tasks: 4
commits: 4
plan_head_before: 399efed845bc7dc7e6a1dbd26ed74d78172684e4
plan_head_after: 21aa4e5
tech-stack:
added: []
patterns:
- "Optional descriptor facts: new slices are nil-or-empty alike; signpost hands records through untouched"
- "Per-request field options: computed once per type per schema request, merged into a copy, fields.yaml wins, errors fail open with one WARN"
key-files:
created:
- modules/signpost/content_fields_smoke_test.go
- modules/cabana/field_request_options_smoke_test.go
- scripts/check-phase33.sh
- .planning/phases/33-seo-output-audit-and-content/33-release-log.md
modified:
- modules/pact/content.go
- modules/pact/capabilities.go
- modules/cabana/field_type.go
- modules/cabana/http.go
- modules/cabana/schema_types.go
- modules/cabana/admin_openapi.go
- modules/signpost/example_content_test.go
- modules/cabana/example_field_type_test.go
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- docs/plugins/menu-item-types.md
- docs/backend/field-types.md
- modules/pact/README.md
- modules/signpost/README.md
- modules/cabana/README.md
key-decisions:
- "Task 1 Decision A (Jakub 2026-11-04): as-planned; P33-01 descriptor fields/constants/value types and P33-02 FormFieldType.RequestOptions exactly as the plan names them"
- "Task 1 Decision B (Jakub 2026-11-04): WR-02 accepted; pact.RecordSaved.Locale stays the primary tag of the request locale (released in v0.5.2)"
- "Task 1 Decision C (Jakub 2026-11-04): Phase 33 delivers SummerCMS v0.6.1 (patch, additive); additive and marker stages compare with v0.6.0; plans reading v0.5.1/v0.5.2 mean v0.6.0/v0.6.1"
- "RequestOptions receives the application database bound to the request context, nil when none is configured"
patterns-established:
- "Content facts: a content type may set Kind/Tools and per-record PublishedAt/Parents/Authors/TopHeading/Images/Videos/Headings; consumers read them through signpost without importing the content plugin"
- "Phase gate: check-phase33.sh stage engine; a missing repository fails with 'missing <path>'"
requirements-completed: [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]
coverage:
- id: D1
description: "Additive content descriptor fields and value types, handed through signpost.Describe and signpost.Records unchanged; a type without them describes as before"
requirement: "D-93"
verification:
- kind: unit
ref: "modules/signpost/content_fields_smoke_test.go#TestContentDescriptorFields"
status: pass
- kind: unit
ref: "modules/signpost/example_content_test.go#ExampleRegistry_MatchPath"
status: pass
human_judgment: false
- id: D2
description: "pact.FormFieldType.RequestOptions merged into served typeOptions (every screen, fields.yaml wins, once per type per request, error fails open with one warning, unchanged schema without it, form extension fields, authenticated handler only)"
requirement: "SC-3"
verification:
- kind: unit
ref: "modules/cabana/field_request_options_smoke_test.go#TestFieldTypeRequestOptions"
status: pass
- kind: other
ref: "scripts/check-admin-openapi.sh --check"
status: pass
human_judgment: false
- id: D3
description: "Framework API strictly additive against v0.6.0 and no framework line names SEO, a sitemap, JSON-LD or an application"
requirement: "D-106"
verification:
- kind: other
ref: "bash scripts/check-phase33.sh --go --docs --marker --additive"
status: pass
- kind: other
ref: "bash scripts/check-phase33.sh --compat"
status: pass
human_judgment: false
- id: D4
description: "check-phase33.sh with eleven stages; self-test detects an API removal, an interface method, a marker hit, a failing go test and a missing repository"
verification:
- kind: other
ref: "bash scripts/check-phase33.sh --self-test"
status: pass
human_judgment: false
- id: D5
description: "33-release-log.md records v0.6.1 planned contents, the six Phase 32 fixes as already released in v0.5.2, plugin/layer tag state and the release order"
verification: []
human_judgment: true
rationale: "A planning ledger; its accuracy and the release order are Jakub's call before 33-14 tags anything"
duration: 22min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 01: Framework seams, phase gate and release ledger Summary
**Optional content facts on pact.ContentDescriptor/Record/Locale (kind, MCP tools, first publication, parents, authors, top heading, images, videos, heading outline), per-request plugin field options through pact.FormFieldType.RequestOptions, the eleven-stage check-phase33.sh gate and the v0.6.1 release ledger**
## Task 1 answers (Jakub, 2026-11-04)
Later plans (33-02 to 33-14) use these:
1. **Decision A: as-planned.** P33-01 and P33-02 are adopted exactly as written, no rename:
- `pact.ContentDescriptor.Kind string` with `ContentKindArticle = "article"`, `ContentKindCollection = "collection"`, `ContentKindProfile = "profile"`, `ContentKindPage = "page"`;
- `pact.ContentDescriptor.Tools map[string]string` with `ContentToolEdit = "edit"`, `ContentToolHeadings = "headings"`, `ContentToolImageAlt = "image_alt"` (values are full MCP tool names);
- `pact.ContentRecord.PublishedAt *time.Time`, `Parents []ContentRef`, `Authors []ContentPerson`, `TopHeading int`;
- `pact.ContentLocale.Images []ContentImage`, `Videos []ContentVideo`, `Headings []ContentHeading`;
- `ContentRef{Type, ID string}`, `ContentPerson{Name string; Ref ContentRef}`, `ContentImage{URL, Alt string; Decorative bool; Width, Height int}`, `ContentVideo{Provider, ID string}`, `ContentHeading{Level int; Text string}`;
- `pact.FormFieldType.RequestOptions func(ctx context.Context, db *gorm.DB) (map[string]any, error)` with `json:"-"`.
2. **Decision B: WR-02 accepted.** `pact.RecordSaved.Locale` stays the primary tag of the request locale, as released in v0.5.2 (commit 1583651). 32-UAT.md is left to verify-work.
3. **Decision C: version base.** Tags v0.5.2, v0.5.3 and v0.6.0 already exist and contain all six Phase 32 fixes (676ca53, 5471b66, d660cd0, 468af06, 1583651, 0e187cf). This phase delivers **SummerCMS v0.6.1** (patch, additive per D-106). `check-phase33.sh --additive` compares against the newest tag not containing HEAD (computed, currently v0.6.0); `--marker` diffs from v0.6.0 (`PHASE33_BASE`). **Where a Phase 33 plan says v0.5.1 (base) or v0.5.2 (target), read v0.6.0 and v0.6.1.** Nothing is pushed or tagged before 33-14.
## Performance
- **Duration:** 22 min (continuation; Task 1 answered before this run)
- **Started:** 2026-11-04T07:26:44Z
- **Completed:** 2026-11-04T07:49:00Z
- **Tasks:** 4 (Task 1 decision, Tasks 2-4 executed)
- **Files modified:** 19
## Accomplishments
- Content types can describe kind, fixing MCP tools, first publication, ordered parents, authors, top heading level, images (with alt and a decorative flag), videos and the heading outline; signpost hands them through, and a type without them is unaffected.
- A plugin field type can serve per-request options to its element through the existing `typeOptions` attribute; the compiled schema is never mutated and errors fail open.
- `scripts/check-phase33.sh` gates the phase: `--self-test`, `--go`, `--docs`, `--marker`, `--additive`, `--admin`, `--plugins` and `--compat` all pass on this host.
- `33-release-log.md` records the v0.6.1 contents and the release order 33-14 follows.
## Task Commits
1. **Task 1: Confirm descriptor fields, RequestOptions, RecordSaved.Locale meaning**: no commit (decision, answers above)
2. **Task 2: Content descriptor fields (tracer)**: `561a56c` (feat)
3. **Task 3: Per-request options for plugin field types**: `4cd35c9` (feat)
4. **Task 4: Phase gate script and release log**: `6ea43bb` (test, group A), `21aa4e5` (docs, group B)
**Plan metadata:** this SUMMARY commit (docs)
## Files Created/Modified
- `modules/pact/content.go`: Kind, Tools, the kind and tool constants, the record and locale fields and the five value types.
- `modules/pact/capabilities.go`: `FormFieldType.RequestOptions` with its doc comment (no secrets, fails open).
- `modules/cabana/field_type.go`: `mergeRequestOptions` (once per type, copy-merge, fields.yaml wins, one WARN on error).
- `modules/cabana/http.go`: form schema route calls it on the request's own field slice; `requestDB` helper.
- `modules/cabana/schema_types.go`, `modules/cabana/admin_openapi.go`, `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`: typeOptions description now names per-request options.
- `modules/signpost/content_fields_smoke_test.go`: `TestContentDescriptorFields` (full type, plain type, nil vs empty slices).
- `modules/cabana/field_request_options_smoke_test.go`: `TestFieldTypeRequestOptions`, one subtest per behaviour line plus the authenticated-handler check (T-33-01).
- `modules/signpost/example_content_test.go`, `modules/cabana/example_field_type_test.go`: runnable examples that the docs embed.
- `docs/plugins/menu-item-types.md`, `docs/backend/field-types.md`, `modules/pact/README.md`, `modules/signpost/README.md`, `modules/cabana/README.md`: documentation of every new identifier.
- `scripts/check-phase33.sh`: phase gate.
- `.planning/phases/33-seo-output-audit-and-content/33-release-log.md`: release ledger.
## Decisions Made
- The Task 1 answers above.
- `RequestOptions` gets the application database bound to the request context, or nil when the application has none. The test service has no database, and a type that needs one can check for nil.
- The compat stage reads the sitemap and Journal plugins at their releases before the phase (v0.1.2, v0.3.1) and sm-demo-app at its HEAD when 33-01 ran (da481c9). It uses read-only snapshots, not worktrees in the sibling repos.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Version base moved from v0.5.1/v0.5.2 to v0.6.0/v0.6.1 (Jakub's ruling, Decision C)**
- **Found during:** Task 1 (checkpoint), applied in Task 4
- **Issue:** The plan was written before v0.5.2, v0.5.3 and v0.6.0 were tagged, so the six Phase 32 fixes are already released.
- **Fix:** The additive stage still computes the newest tag that does not contain HEAD (v0.6.0). The marker base is `PHASE33_BASE=v0.6.0`. The release log lists the fixes as "already released in v0.5.2" and plans v0.6.1. The commit subject of group B is `docs(33-01): release log for v0.6.1`, not `... v0.5.2`.
- **Files modified:** scripts/check-phase33.sh, 33-release-log.md
- **Committed in:** 6ea43bb, 21aa4e5
**2. [Rule 3 - Blocking] Plugin tags moved past the plan's ranges**
- **Found during:** Task 4
- **Issue:** The plan pasted `sm-seo-plugin v0.1.0..HEAD` and `sm-journal-plugin v0.2.5..HEAD` as unreleased fixes. Those commits are now released in sm-seo-plugin v0.1.1 and sm-journal-plugin v0.2.6 to v0.3.1, and no plugin or layer has unreleased commits.
- **Fix:** The release log has a table of the newest tag per repository with "none" unreleased. Versions are still read from `git tag` in 33-14.
- **Committed in:** 21aa4e5
**3. [Rule 3 - Blocking] Docs checker requires src= snippets for go code blocks**
- **Found during:** Task 3
- **Issue:** The docs checker refuses a hand-written go snippet in docs/backend/field-types.md.
- **Fix:** Added a runnable `headingLevels` func and `ExampleCompileFormWithTypes_requestOptions` to modules/cabana/example_field_type_test.go. The page embeds the func through `src=`.
- **Committed in:** 4cd35c9
**4. [Rule 2 - Marker] pact README addition moved to a new bullet**
- **Found during:** Task 2
- **Issue:** Editing the existing descriptor bullet re-added its pre-existing word "sitemaps" as a changed line, which the D-90 marker stage would flag.
- **Fix:** The new facts are described in a separate "Content facts" bullet, and the original line is unchanged.
- **Committed in:** 561a56c
**5. [TDD] Task 3 RED evidence not committed separately**
- **Found during:** Task 3
- **Issue:** The plan's commit-group table gives Task 3 one commit (`feat(cabana): ...`), while tdd.md's cycle commits `test(...)` first. The plan is `type: execute`, not `type: tdd`.
- **Fix:** I followed the plan's single commit. RED was recorded before implementing: TestFieldTypeRequestOptions failed on 4 behaviour subtests (`typeOptions=map[rows:4]`, `calls=0`, no warning logged), and `check tdd-red-evidence` returned RED_EVIDENCE_OK. GREEN then passed all 8 subtests.
---
**Total deviations:** 5 (3 blocking, 1 marker-safety, 1 TDD commit-shape note)
**Impact on plan:** The API shape and behaviour are exactly as planned. Only version numbers, the ledger contents and the doc mechanics changed.
## Issues Encountered
- The compat stage reports sm-feedback-plugin, sm-presentations-plugin and sm-quizzes-plugin as "skipped (fails at v0.6.0 too)": they do not resolve the framework through a scratch workspace at either revision. These failures predate this plan and are not caused by it.
- shellcheck warns SC2034 (`type` unused) in the copied `snapshot_submodules`. check-phase32.sh has the same warning, so I left it as is.
## Verification run
- `go vet ./... && go test -short ./...`: green after every commit.
- `go test ./modules/signpost -run 'TestContentDescriptorFields|Example' -v`: PASS (5 subtests, 3 examples).
- `go test ./modules/cabana -run 'TestFieldTypeRequestOptions|TestFormExtensionUnextendedSchemaUnchanged' -v`: PASS (8 subtests).
- `scripts/check-admin-openapi.sh --check`: exit 0. `check-phase33.sh --admin` (openapi + dist): passed.
- `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`: no problems.
- `bash scripts/check-phase33.sh --self-test`: passed (37 checks). Without an argument it exits 2 and prints the usage.
- `bash scripts/check-phase33.sh --go --docs --marker --additive`: PASS all four; additive: cabana +0, pact +35, signpost +0 lines against v0.6.0.
- `bash scripts/check-phase33.sh --plugins`: passed (sm-seo-plugin 77 and sm-journal-plugin 232 vitest tests).
- `bash scripts/check-phase33.sh --compat`: passed.
- Not run here: `--layers` (vue-seo-layer `testing/guard-check.mjs` arrives in 33-08), `--consumers`, `--docker`.
## User Setup Required
None. No external service configuration is required.
## Next Phase Readiness
- 33-02 (Blog Editor) can use `RequestOptions` for `headingLevels` and `genericAnchors`, and 33-03 onward can fill and read the content facts.
- 33-14 tags framework **v0.6.1** and follows 33-release-log.md.
## Self-Check: PASSED
- FOUND: modules/pact/content.go, modules/pact/capabilities.go, modules/signpost/content_fields_smoke_test.go, modules/cabana/field_request_options_smoke_test.go, scripts/check-phase33.sh, 33-release-log.md
- FOUND commits: 561a56c, 4cd35c9, 6ea43bb, 21aa4e5
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,346 @@
---
phase: 33-seo-output-audit-and-content
plan: 02
subsystem: journal
tags: [sm-journal-plugin, headings, h1-setting, blog-editor, tiptap, content-descriptor, mcp, media-names, bluemonday]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-01 seams: pact content facts (Kind, Tools, PublishedAt, Parents, Authors, TopHeading, Images, Videos, Headings) and pact.FormFieldType.RequestOptions"
- phase: 32-seo-foundation
provides: "Journal content descriptors, journal-tag, RecordSaved emission on API writes"
provides:
- "Journal H1 setting content_h1_to_h2 (settings row, config key golem15.journal.content_h1_to_h2 as fallback) with H1 to H2 conversion on every Journal rich-text write path (post content and category pillar text)"
- "content_h1_to_h2 flag on GET /posts/{slug} and every GET /categories row; notices key on API write answers"
- "journal:fix-headings and POST /_journal/api/v1/fix-headings over stored post and category content"
- "GET/PUT /_journal/api/v1/settings and POST /_journal/api/v1/images/alt"
- "MCP tools journal_get_settings, journal_update_settings, journal_fix_headings, journal_set_image_alt"
- "Descriptor facts for posts, categories and tags, and the journal-author content type (golem15.journal.author_pages)"
- "Blog Editor J1-J4 on RequestOptions (heading levels, outline panel, link rel, generic-anchor warning, image naming)"
- "HTML policy keeps rel=nofollow|sponsored|ugc on a; classes.MediaFileName slugs uploaded image names"
affects: [33-04, 33-06, 33-09, 33-10, 33-11, 33-13, 33-14]
actuals:
tokens: 55440
tasks: 4
commits: 9
plan_head_before: f7bfb3313b718e31cc754c87e4e0f9b0dcf95c0a
plan_head_after: 0edf6e1b67d4f7d8f62980e30d52292cce94faf3
tech-stack:
added: []
patterns:
- "Byte-preserving HTML rewrites on the x/net/html tokenizer (ConvertH1 rewrites only the tag name; SetImageAlt rewrites only the matching img from its token)"
- "One setting reader (EffectiveH1ToH2): settings row, else config key, else true"
- "Per-request field options from settings (RequestOptions), read by the element from its options attribute"
- "A parse rule carried by a never-created node type (HeadingOneAsTwo) to map h1 to the heading node at level 2"
key-files:
created:
- ../sm-journal-plugin/updates/202610280001_add_golem15_journal_settings_headings.go
- ../sm-journal-plugin/config/config.yaml
- ../sm-journal-plugin/classes/headings.go
- ../sm-journal-plugin/classes/heading_fix.go
- ../sm-journal-plugin/classes/journal_settings.go
- ../sm-journal-plugin/classes/image_alt.go
- ../sm-journal-plugin/classes/content_facts.go
- ../sm-journal-plugin/classes/media_names.go
- ../sm-journal-plugin/console/fix_headings.go
- ../sm-journal-plugin/controllers/api/settings.go
- ../sm-journal-plugin/controllers/api/maintenance.go
- ../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/headings_test.go
- ../sm-journal-plugin/journal_descriptor_fields_test.go
- ../sm-journal-plugin/classes/media_names_test.go
- ../sm-journal-plugin/tests/blogeditor/additions.test.ts
modified:
- ../sm-journal-plugin/models/settings.go
- ../sm-journal-plugin/models/settings/fields.yaml
- ../sm-journal-plugin/plugin.go
- ../sm-journal-plugin/routes.go
- ../sm-journal-plugin/mcp.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/content_descriptors.go
- ../sm-journal-plugin/classes/menu_item_types.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_helpers.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/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
key-decisions:
- "Version mapping (Jakub's 33-01 ruling): the framework target is SummerCMS v0.6.1 (base v0.6.0); sm-journal-plugin is at v0.3.1, its next version is read from git tag in 33-14. go.mod still requires v0.6.0 with the existing replace to ../summercms.go; the require bump waits for the v0.6.1 tag"
- "P33-04 as planned: EffectiveH1ToH2 reads the settings row, then golem15.journal.content_h1_to_h2, then true"
- "API H1 conversion converts content and content_html together (default and translated); ConvertH1 is byte-preserving, so markdown content changes only where it holds raw <h1> tags. An update that does not send content converts nothing"
- "API notices: one {message, count} per converted locale, with a locale key on a translation's notice (additive)"
- "POST /fix-headings defaults to dry_run true (mirrors the CLI); POST /images/alt defaults to dry_run false (an explicit per-change edit); set_image_alt also checks post.CanEdit per post (403)"
- "The stored-heading fix rewrites translate rows directly (attribute_data JSON, translate's own encoding) so a translation in a since-disabled locale is converted too and SetTranslated's enabled-locale check cannot abort the batch"
- "Editor schema keeps H5 and H6 (levels 2-6 while the setting is on, 1-6 while off); the toolbar offers only H2-H4 or H1-H4. A strict [2,3,4] schema would drop stored H5/H6 on the first edit"
- "Post Parents are the post's categories by name (the order :category takes its first category in)"
- "journal-author: Controller empty, Model Backend\\Models\\User, Published = has a published post, LastMod = newest update of their published posts"
- "MediaFileName applies to both the editor upload and POST /_journal/api/v1/media/upload (D-85 server backstop); the response shape is unchanged, the stored name is now slugged"
patterns-established:
- "Content plugins answer their own fixing tools in ContentDescriptor.Tools (journal_update_post, journal_fix_headings, journal_set_image_alt)"
- "Editor options arrive per request through RequestOptions; the element parses them from its options attribute with defaults"
requirements-completed: [SC-2, SC-3, D-50, D-51, D-81, D-83, D-85, D-96, D-43, D-93, D-10]
coverage:
- id: D1
description: "H1 setting (column, config key, Headings and links tab) with admin save conversion and the post-title notice; GET /posts/{slug} reports content_h1_to_h2; setting off stores H1 unchanged; switching never rewrites stored content"
requirement: "D-50"
verification:
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestHeadingConversionTracer"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/headings_test.go#TestConvertH1"
status: pass
- kind: integration
ref: "../sm-journal-plugin/updates/settings_headings_migration_test.go#TestSettingsHeadingsMigration"
status: pass
human_judgment: false
- id: D2
description: "Conversion on the API (default and translated), markdown, import and category admin paths with notices; GET /categories rows carry content_h1_to_h2"
requirement: "D-96"
verification:
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestHeadingConversion"
status: pass
human_judgment: false
- id: D3
description: "journal:fix-headings (dry run, --apply, nothing to do, refusal exit 1) and POST /fix-headings over posts and categories with RecordSaved per changed record; settings API with manage_settings (401/403/422)"
requirement: "D-10"
verification:
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestFixHeadingsCommand"
status: pass
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestJournalSettingsTools"
status: pass
human_judgment: false
- id: D4
description: "POST /images/alt and MCP journal_set_image_alt: tokenizer-escaped alt, all-or-nothing 422, dry run, decorative alt, translated content, access_posts; four new MCP tools listed and bound to routes"
requirement: "D-85"
verification:
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestSetImageAlt"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/image_alt_test.go#TestSetImageAlt"
status: pass
- kind: unit
ref: "../sm-journal-plugin/mcp_test.go#TestJournalMCP"
status: pass
- kind: unit
ref: "../sm-journal-plugin/mcp_smoke_test.go#TestJournalMCPSmoke"
status: pass
human_judgment: false
- id: D5
description: "Descriptor facts for posts (PublishedAt, Parents, Authors, Images, Videos, Headings, TopHeading, Kind, Tools), categories (Parents, Headings, TopHeading, Kind, Tools) and tags (Kind)"
requirement: "D-93"
verification:
- kind: integration
ref: "../sm-journal-plugin/journal_descriptor_fields_test.go#TestJournalDescriptorFields"
status: pass
human_judgment: false
- id: D6
description: "journal-author content type with author_pages on and off"
requirement: "D-43"
verification:
- kind: integration
ref: "../sm-journal-plugin/journal_descriptor_fields_test.go#TestJournalAuthorType"
status: pass
human_judgment: false
- id: D7
description: "Stored HTML keeps rel with only nofollow/sponsored/ugc; MediaFileName slug rule on uploads; corpus pins a rel link and a decorative image"
requirement: "D-83"
verification:
- kind: unit
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestEditorOutputPassesPolicy"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/media_names_test.go#TestMediaFileName"
status: pass
human_judgment: false
- id: D8
description: "Blog Editor J1-J4 behaviour: heading levels per setting via RequestOptions, outline flags and counts with CLDR plurals, rel checkboxes and generic warning, slugged file name and decorative alt; dist matches a fresh build"
requirement: "D-51"
verification:
- kind: automated_ui
ref: "../sm-journal-plugin/tests/blogeditor/additions.test.ts"
status: pass
- kind: integration
ref: "../sm-journal-plugin/headings_test.go#TestBlogEditorRequestOptions"
status: pass
- kind: other
ref: "bash ../sm-journal-plugin/scripts/check-dist.sh"
status: pass
human_judgment: false
- id: D9
description: "J2 outline look and wrapping in the real admin (long headings wrap with a hanging indent at 360 px, 12px indent per level, icon and colours in light and dark), J3/J4 form layout"
requirement: "D-81"
verification: []
human_judgment: true
rationale: "Visual layout in the embedded admin SPA; happy-dom tests cover structure and behaviour, not rendering"
- id: D10
description: "The H1 setting switch saved from Settings › Journal in the admin"
verification: []
human_judgment: true
rationale: "Blocked by a pre-existing framework issue (lagoon.Validate rejects the string/url rule tokens; the Journal settings screen answers 500 on save, deferred-items.md); the API and MCP path is tested"
duration: 60min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 02: Journal headings, editor additions and content facts Summary
**Per-site Journal H1 setting with byte-preserving H1→H2 conversion on every rich-text write path (admin, API/MCP, markdown, import, category pillar text), journal:fix-headings, the settings and bulk image-alt routes with four new MCP tools, descriptor facts plus a journal-author content type, and Blog Editor J1–J4 (heading levels, outline, link rel, slugged image names)**
## Read first: version mapping and things to decide
- **Versions (Jakub's 33-01 ruling).** Read "v0.5.1" in the plan as SummerCMS v0.6.0 and "v0.5.2" as v0.6.1. The Journal changes need v0.6.1 (the content facts and `RequestOptions` are new there). `sm-journal-plugin/go.mod` still requires `v0.6.0` and resolves the framework through its existing `replace => ../summercms.go`; I did not touch that replace. The require bump to v0.6.1 happens when 33-14 tags the framework. The Journal was at v0.3.1 before this plan, and its next version is read from `git tag` in 33-14. Nothing was pushed or tagged.
- **No breaking change to an existing Journal consumer.** Every API change adds something: `content_h1_to_h2` on `GET /posts/{slug}` and on the category rows, `notices` on write answers, and new routes and tools. Two behaviour changes need a line in the release notes:
1. The H1 setting defaults to **on** (D-50, Jakub 2026-10-28). After the upgrade, new saves on a site turn H1 into H2 until someone switches the setting off. Stored content is never rewritten unless `journal:fix-headings --apply` runs.
2. Uploaded image names are now slugged on the server (D-85). This covers `POST /_journal/api/v1/media/upload` too: `My Photo.JPG` is stored as `my-photo.jpg`. The response shape is the same, and clients use the returned URL.
- **Pre-existing framework issue (deferred, needs a decision).** Saving Settings › Journal in the admin answers **500**, and it did before this plan too. `lagoon.Validate` does not know the `string` and `url` rule tokens that `Settings.Rules()` has used for a long time. So until the framework is fixed, the new switch can only be changed through `PUT /_journal/api/v1/settings` or MCP `journal_update_settings`. Details are in `deferred-items.md`, and there is a WINDOWS.md entry. The fix belongs in lagoon (v0.6.1).
## Performance
- **Duration:** about 60 min
- **Started:** 2026-11-04T08:38:29Z
- **Completed:** 2026-11-04T09:38:48Z
- **Tasks:** 4 (9 commits in sm-journal-plugin)
- **Files modified:** 63 in sm-journal-plugin
## Accomplishments
- The Journal H1 setting (`content_h1_to_h2`, Headings and links tab, config fallback) converts H1 to H2 on every Journal rich-text write path and sends a notice. Post and category answers expose the setting. Stored content changes only through `journal:fix-headings --apply` or `journal_fix_headings`.
- Journal fixes its own content through MCP: `journal_get_settings`, `journal_update_settings`, `journal_fix_headings` and `journal_set_image_alt` (D-10, D-85, P33-05).
- Descriptors give the SEO, sitemap and audit plugins publication dates, parents, authors, images (with alt and the decorative flag), YouTube videos, the heading outline, the top heading level, the kind and the fixing tools. Author pages are a content type (`journal-author`).
- Blog Editor J1–J4: the heading levels follow the setting (per request through `RequestOptions`). There is a read-only outline that flags skipped, empty, link-only and Source-mode H1 headings and generic link texts. External links get rel checkboxes, a generic link text gets a non-blocking warning, and an image gets a slugged file name and a required alt (or is marked decorative) before upload.
- On the server, the HTML policy keeps `rel` when it holds only `nofollow`, `sponsored` and `ugc`, and `MediaFileName` applies the same slug rule as the editor.
## Task Commits (sm-journal-plugin, branch main, not pushed)
1. **Task 1: H1 setting with the admin save conversion (tracer)**: `305f94a` (feat)
2. **Task 2: other write paths, fix-headings, settings API, MCP tools**: `faaa9b0` (group A), `5210b64` (group B), `af25eaa` (group C)
3. **Task 3: descriptor facts and journal-author**: `e8e77a1` (group A), `805bfdb` (group B)
4. **Task 4: Blog Editor J1–J4, rel policy, media names, README**: `391435b` (group A), `4b09bd9` (groups B and C together), `0edf6e1` (group D, docs)
**Plan metadata:** the SUMMARY commit in summercms.go (docs), then ROADMAP alone.
## Files Created/Modified (sm-journal-plugin)
- `updates/202610280001_add_golem15_journal_settings_headings.go`: `content_h1_to_h2 BOOLEAN NOT NULL DEFAULT TRUE`, `generic_anchors TEXT NULL`, with rollback.
- `config/config.yaml`, `plugin.go`: `ConfigFS` (`pact.HasConfig`) and the `journal:fix-headings` registration.
- `classes/journal_settings.go`: `EffectiveH1ToH2`, `GenericAnchors`, `DefaultGenericAnchors`, `H1ToH2Key`.
- `classes/headings.go`: `ConvertH1`, `CountH1`, `ConvertContentH1`, `ConvertTranslationH1`.
- `classes/heading_fix.go`: `FixStoredHeadings`, `HeadingFix` and the refusal text. They are shared by the command and the API.
- `classes/image_alt.go`: `SetImageAlt` on the tokenizer.
- `classes/content_facts.go`: the image, video and heading parse, category refs and the author projection.
- `classes/content_descriptors.go`, `classes/menu_item_types.go`: the facts, `AuthorPagesKey`, and the `journal-author` type (`MenuTypeAuthor`, `DefaultAuthorPattern`).
- `classes/blog_editor.go`: the Decode conversion with the post or category notice, and `RequestOptions` (`headingLevels`, `genericAnchors`). `safeUploadName` now uses `MediaFileName`.
- `classes/format_html.go`: the rel rule. `classes/media_names.go`: `MediaFileName`. `classes/media_store.go`: applies it.
- `controllers/api/posts.go`, `posts_helpers.go`: conversion inside the write transaction, the `notices` answer and the category flag.
- `controllers/api/settings.go`: `ShowSettings`, `UpdateSettings`. `controllers/api/maintenance.go`: `FixHeadings`, `SetImageAlt`. `routes.go`: four routes.
- `console/fix_headings.go`. `console/import_posts.go`: `ImportFromDirConfig`, `ImportStats.H1Converted`. `ImportFromDir` keeps its signature.
- `mcp.go`: four tools.
- `assets/src/blogeditor/{element,extensions,toolbar,labels,outline,linkform,imageform}.ts`, `blogeditor.css`, `assets/dist/*`: J1–J4.
- `lang/{en,pl}/lang.yaml`: the C27 keys with the P33-54 wording, `editor.h1_converted` and `editor.h1_converted_category` (CLDR plurals), and `menuitem.journal_author`.
- `README.md`: the setting and its precedence, the write paths, the command, the routes, the MCP tools, `journal-author`, the content facts, the editor additions and the rel and name rules.
## Decisions Made
See `key-decisions` in the frontmatter. The main ones:
- The API converts `content` and `content_html` together.
- A PUT without `content` converts nothing.
- `fix-headings` is a dry run by default. `images/alt` is not, because it is an explicit edit, and it checks edit rights per post.
- The editor schema keeps H5 and H6 so that stored headings survive an edit.
- Post parents are ordered by category name.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug, deferred root cause] Settings screen save fails in the admin (pre-existing)**
- **Found during:** Task 2 (settings API)
- **Issue:** `lagoon.Validate` rejects the `string` and `url` tokens in `Settings.Rules()`. The admin settings PUT answers 500, and my API handler hit the same error.
- **Fix:** `UpdateSettings` checks `string` and `url` itself (`settingsRule`) and passes the other tokens to `lagoon.Validate`. The admin screen is not fixed, because that needs a framework change; it is logged in `deferred-items.md` and WINDOWS.md.
- **Files modified:** controllers/api/settings.go
- **Verification:** TestJournalSettingsTools (422 for a bad URL and for a non-string value)
- **Committed in:** 5210b64
**2. [Rule 3 - Blocking] The category-content migration test assumed it was the last migration**
- **Found during:** Task 1
- **Fix:** It now rolls back to the step before its own (`RollbackTo`). I added `TestSettingsHeadingsMigration` for the new step.
- **Committed in:** 305f94a
**3. [Rule 1 - Data loss] The heading schema keeps H5 and H6**
- **Found during:** Task 4
- **Issue:** TipTap levels of exactly `[2, 3, 4]` (UI-SPEC C28) would turn stored H5 and H6 into paragraphs on the first edit of their block.
- **Fix:** The schema levels are 2–6 while the setting is on and 1–6 while it is off. The toolbar offers H2–H4 or H1–H4. A dedicated parse rule (`HeadingOneAsTwo`) maps `<h1>` to level 2 without importing `@tiptap/extension-heading`, so no package was added.
- **Committed in:** 4b09bd9
**4. [Rule 3 - Blocking] Existing tests pinned the behaviour this plan changes**
- Tool lists (TestJournalMCP, TestJournalMCPSmoke), menu type lists (TestMenuItemTypesPatterns, TestContentDescriptors), the category key list (categories_test.go), and the corpus test's "no rel" assertion. The upload-name expectations changed too: `zdj-cie-1.png` is now `zdjecie-1.png`, `ok-adka.png` is now `okladka.png`, and `b.JPEG` is now `b.jpeg`. The old upload-on-pick tests (smoke.test.ts, embed.test.ts) were rewritten to the J4 flow, which is pick, then the form, then Insert uploads, then the image is inserted on the result. I updated each test to the new behaviour; I did not remove any.
- **Committed in:** 5210b64, af25eaa, 805bfdb, 391435b, 4b09bd9
**5. [Plan shape] Task 4 groups B and C are one commit**
- **Issue:** J1–J4 share `element.ts`, `labels.ts` and the bundle. If B were committed without C, the import graph would not build.
- **Fix:** One commit, `feat: editor heading levels, outline, link rel, generic-anchor warning and image naming`.
**6. [Plan shape] Helper and test files the plan did not list**
- `classes/heading_fix.go` holds the logic the command and the API share. The tests not in the plan are `classes/headings_test.go`, `classes/image_alt_test.go`, `updates/settings_headings_migration_test.go`, `tests/blogeditor/additions.test.ts` and `TestBlogEditorRequestOptions`. The slug helpers live in `imageform.ts`. I added `controllers.PermissionManageSettings` and `console.ImportFromDirConfig`; both are additive.
- For the acceptance greps, the C27 console copy appears in `console/fix_headings.go` (doc comment and long help). The runtime refusal string is `classes.H1ConversionOffText`.
**7. [TDD note] RED evidence**
- Task 2 group A tests were written right after the code, so no RED was captured for them. For Task 2 groups B and C and for Task 3, I ran the tests against stubs first. Each failed on its target assertion: TestFixHeadingsCommand had dry-run output `""`, TestJournalSettingsTools got 404, TestSetImageAlt got 404, the TestJournalMCP tool list mismatched, and the descriptor kinds were empty and journal-author was nil. `check tdd-red-evidence` only reads TAP, so I converted the Go output to TAP, and it then returned RED_EVIDENCE_OK.
**8. [Cosmetic] Attribute order of new editor links**
- TipTap renders `rel` before `href` (`<a rel="nofollow sponsored" href="…">`). The policy and the layer do not depend on attribute order. Stored links keep their own bytes.
---
**Total deviations:** 8 (1 deferred pre-existing bug, 1 blocking test fix, 1 data-loss guard, 1 test update set, 2 plan-shape, 1 TDD note, 1 cosmetic)
**Impact on plan:** Every must-have truth is met through the API, MCP, editor and descriptors. The one exception is the admin switch save, which a pre-existing framework issue blocks.
## Issues Encountered
- `go test ./... -run X` prints "no tests to run" for the subpackages. The plan's `fails_when` lists that phrase, but each named root test passes: the line is from the other packages.
- vue-journal-layer holds a copy of `editor-corpus.json`. Its policy mirror (htmlGate) has to accept `rel` with the three tokens and `alt=""`, which is 33-09's work.
## User Setup Required
None. No external service configuration is required.
## Next Phase Readiness
- 33-04 (image and News sitemaps), 33-06 (JSON-LD, breadcrumbs), 33-09 (layer title tag and htmlGate following `content_h1_to_h2`) and 33-10 (CMS checks through `Tools`, `TopHeading`, `Images`, `Headings`) can read what this plan provides.
- 33-14: bump the Journal `go.mod` to the v0.6.1 tag, tag the Journal from `git tag` (after v0.3.1), and include the release notes about the default-on setting and slugged upload names.
- Open: the framework fix for `lagoon.Validate` `string`/`url` (deferred-items.md).
## Self-Check: PASSED
- FOUND: the 18 created files listed in key-files.created (checked with `[ -f ]`).
- FOUND commits in sm-journal-plugin: 305f94a, faaa9b0, 5210b64, af25eaa, e8e77a1, 805bfdb, 391435b, 4b09bd9, 0edf6e1. `git rev-list --count f7bfb33..HEAD` = 9.
- Verification: `go vet ./...` and `go test -short ./...` are green. The full Docker `go test ./...` is green (root, classes, controllers, api, models, updates). `npm run test` passed 244 of 244. `check-dist.sh` matches. `check-phase33.sh --plugins` passed.
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,437 @@
---
phase: 33-seo-output-audit-and-content
plan: 03
subsystem: sitemap
tags: [sm-sitemap-plugin, sitemap-index, slot-table, stable-numbering, lastmod, hreflang, filter-contract, listing, conga, settings-singleton, breaking-change]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-01 version ruling (framework target v0.6.1, base v0.6.0) and the signpost content facts"
- phase: 32-seo-foundation
provides: "signpost content descriptors and Records (D-93), pact.RecordSaved/RecordDeleted (D-92), form extensions (D-91)"
provides:
- "sm-sitemap-plugin per-type format: GET /sitemap.xml index and GET /_sitemap/files/{file} served from the slot table golem15_sitemap_urls (D-30)"
- "Stable file numbering (classes.AssignSlots), file_size setting 1..50 000 (default 1000), renumbering only on a size change"
- "Event-driven lastmod (RecordSaved with changes outside golem15.sitemap.lastmod_ignore), RecordDeleted holes"
- "Rebuild job golem15.sitemap.rebuild: debounced after record events (rebuild_pending_until), dispatched on settings save, nightly sitemap:rebuild 03:00, sitemap:rebuild [--queue]"
- "hreflang xhtml:link alternates with x-default (D-55 to D-57)"
- "Package git.golem15.com/golem15/sm-sitemap-plugin/contract: Filter (consumed through app.Lookup), Listing (published by the plugin), reasons (D-95)"
- "Settings singleton golem15.sitemap.settings (Files with S1 partial, Content types extension tab, Additional addresses); golem15_sitemap_types"
- "Migration of the Phase 17 definition url items into extra_urls, frontend_url into site_url, frontend_default_locale into default_locale (D-32)"
- "sitemap:import and sitemap:import-winter retargeted to the additional addresses"
affects: [33-04, 33-05, 33-07, 33-08, 33-13, 33-14]
actuals:
tokens: 60600
tasks: 4
commits: 7
plan_head_before: 049f3ee270ed9ac2619f357626914b1a8caac780
plan_head_after: 64453d2a914f00a8310f76f806a8858d5d4345da
tech-stack:
added: []
patterns:
- "Slot table: every address is a row with (grp, file_no); a rebuild keeps numbers, appends oldest first, leaves holes; numbered_size detects a file size change"
- "One rebuild transaction that locks settings row 1 (SELECT ... FOR UPDATE), so two rebuilds never interleave"
- "Debounce through a conditional UPDATE of rebuild_pending_until plus conga Enqueue in the same transaction; the job clears it when it starts"
- "Published contract sub-package without init, so other plugins import the seam without registering the plugin"
key-files:
created:
- ../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/settings.go
- ../sm-sitemap-plugin/classes/types.go
- ../sm-sitemap-plugin/classes/dispatch.go
- ../sm-sitemap-plugin/classes/lastmod.go
- ../sm-sitemap-plugin/classes/listing.go
- ../sm-sitemap-plugin/models/url.go
- ../sm-sitemap-plugin/models/type_setting.go
- ../sm-sitemap-plugin/models/legacy.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/controllers/settings.go
- ../sm-sitemap-plugin/controllers/settings/config_form.yaml
- ../sm-sitemap-plugin/controllers/settings/_sitemap_files.htm
- ../sm-sitemap-plugin/extensions_settings.go
- ../sm-sitemap-plugin/jobs.go
- ../sm-sitemap-plugin/listeners.go
- ../sm-sitemap-plugin/schedule.go
- ../sm-sitemap-plugin/console/rebuild.go
- ../sm-sitemap-plugin/sitemap_index_test.go
- ../sm-sitemap-plugin/content_fixture_test.go
- ../sm-sitemap-plugin/filter_fixture_test.go
- ../sm-sitemap-plugin/classes/slots_test.go
- ../sm-sitemap-plugin/classes/lastmod_test.go
modified:
- ../sm-sitemap-plugin/plugin.go
- ../sm-sitemap-plugin/routes.go
- ../sm-sitemap-plugin/admin.go
- ../sm-sitemap-plugin/models/settings.go
- ../sm-sitemap-plugin/models/settings/fields.yaml
- ../sm-sitemap-plugin/controllers/admin_registry.go
- ../sm-sitemap-plugin/console/import.go
- ../sm-sitemap-plugin/console/import_winter.go
- ../sm-sitemap-plugin/console/helpers.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/plugin.yaml
- ../sm-sitemap-plugin/go.mod
key-decisions:
- "Task 1 ANSWERED: as-proposed (Jakub, 2026-11-04): index stays /sitemap.xml, files /sitemap-<slug>-<n>.xml served through /_sitemap/files/{file}, /sitemap.xsl removed, one settings singleton, url items to Additional addresses, priority/changefreq/tiers/manifest removed, definitions table kept unread"
- "Version mapping (33-01 ruling): framework target v0.6.1, base v0.6.0; go.mod still requires summercms v0.6.0 with the existing replace => ../summercms.go; the bump is 33-14's"
- "Settings validation runs in the controller's FormBeforeUpdate (Settings.Validate), not lagoon.HasBeforeValidate: cabana's CRUD path turns a BeforeValidate error into a 500"
- "Content type titles (the HTML sitemap heading) are stored as a JSONB locale map on golem15_sitemap_types, not through the translation writer"
- "Rows left out (deleted, unpublished, noindex, type) keep their file number as a hole; a renumber drops the numbers of left-out rows"
- "S1 links files to the backend routes (/sitemap.xml, /_sitemap/files/<name>): the partial kit keeps only root-relative hrefs; the note names the public index URL"
- "The index answers 404 while no site address is configured, and an empty sitemapindex before the first rebuild"
- "A Filter error or a wrong number of decisions fails the rebuild (the files stay as they were); an invalid Decision.Group is ignored with a warning"
- "x-default points to the default_locale setting's address, else the record's first listed locale; alternates list only listed (not excluded) locales"
- "Reserved slugs images and news (33-04's image and News sitemaps) are refused as type file names"
patterns-established:
- "Plugins publish narrow seams in a contract sub-package with no init; consumers Lookup the interface and work without it"
- "Fake content plugin over an in-memory store for testing signpost consumers without database tables"
requirements-completed: [SC-1, D-30, D-32, D-55, D-56, D-57, D-93, D-95, D-24, D-22]
coverage:
- id: D1
description: "GET /sitemap.xml answers an index of per-type files sitemap-<slug>-<n>.xml with lastmod; each file at most file_size URLs with absolute <loc> and real <lastmod>, no priority or changefreq; drafts absent; escaping; unknown files 404; /sitemap.xsl 404"
requirement: "D-30"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestSitemapIndex"
status: pass
- kind: unit
ref: "../sm-sitemap-plugin/routes_test.go#TestFileNames"
status: pass
human_judgment: false
- id: D2
description: "Stable numbering: holes on delete, appends to the last file with room, renumber only on file_size change"
requirement: "D-30"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestStableNumbering"
status: pass
- kind: unit
ref: "../sm-sitemap-plugin/classes/slots_test.go#TestAssignSlots"
status: pass
human_judgment: false
- id: D3
description: "lastmod moves only on a real change (per locale for translated changes); no-op saves, ignored attributes and rebuilds leave it; deletes leave the files"
requirement: "D-30"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestLastmod"
status: pass
- kind: unit
ref: "../sm-sitemap-plugin/classes/lastmod_test.go#TestRealChanges"
status: pass
human_judgment: false
- id: D4
description: "hreflang alternates for each translated locale plus x-default; none for a missing translation or a single locale"
requirement: "D-55"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestHreflangAlternates"
status: pass
human_judgment: false
- id: D5
description: "contract.Filter batches per page; Exclude leaves the files counted by reason, Group moves to sitemap-offers-1.xml, Groups titles reach Listing.Settings; without a Filter everything published is listed; Listing.Entries paging"
requirement: "D-95"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestFilterExcludes"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestNoFilter"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestListing"
status: pass
human_judgment: false
- id: D6
description: "Rebuilds: debounced to one job per delay after record events, dispatched on save, nightly 03:00, sitemap:rebuild [--queue]"
requirement: "D-30"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestDebouncedRebuild"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestRebuildCommand"
status: pass
- kind: unit
ref: "../sm-sitemap-plugin/commands_documented_test.go#TestScheduleAndJobs"
status: pass
human_judgment: false
- id: D7
description: "Settings singleton (one tile, Files/Content types/Additional addresses), errors.* refusals, save dispatches a rebuild, type off leaves files with reason type, S1 empty/building/files/left-out states"
requirement: "D-32"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestSettingsSingleton"
status: pass
- kind: unit
ref: "../sm-sitemap-plugin/models/settings_test.go#TestSettingsValidate"
status: pass
human_judgment: false
- id: D8
description: "Definitions migration (url items to extra_urls, frontend_url to site_url, locale), definitions table untouched, rollback keeps data; sitemap:import and sitemap:import-winter write the additional addresses"
requirement: "D-32"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_index_test.go#TestMigrateDefinitions"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/import_test.go#TestImportWinter"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/import_test.go#TestImportFile"
status: pass
human_judgment: false
- id: D9
description: "Site URL precedence: setting, golem15.sitemap.site_url, app.url; never the Host header"
requirement: "D-22"
verification:
- kind: unit
ref: "../sm-sitemap-plugin/routes_test.go#TestSiteURLPrecedence"
status: pass
human_judgment: false
- id: D10
description: "The settings screen's look in the real admin (S1 tables and callouts, the Content types tab placement after Files, the repeater)"
verification: []
human_judgment: true
rationale: "Rendering in the embedded admin SPA; the tests cover the schema, the API and the partial's node tree, not the visual layout"
duration: 43min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 03: Golem15.Sitemap rewrite Summary
**sm-sitemap-plugin now serves a sitemap index at /sitemap.xml over per-type files `sitemap-<slug>-<n>.xml` from a slot table with stable numbering, event-driven lastmod, hreflang alternates and a debounced conga rebuild, narrowed by a published `contract.Filter` and readable through `contract.Listing`; the PHP-parity format, the definitions screen and the XSL are gone and the old data migrates.**
## Task 1 ANSWERED: as-proposed (Jakub, 2026-11-04)
- The index stays at `/sitemap.xml`. The per-type files are `/sitemap-<slug>-<n>.xml`, and Go serves them at `/_sitemap/files/{file}`.
- The XSL stylesheet `/sitemap.xsl` is removed and now answers 404. This resolves the todo `.planning/todos/completed/sitemap-xslt-browser-deprecation.md`. If g15office task **w8kupqi3** is still open, it can be closed as resolved by removal (commit `84cb27a` in sm-sitemap-plugin). I could not check its state from here.
- The Phase 17 definitions screen and the settings tile are now one settings singleton. The url items moved to "Additional addresses". priority, changefreq, tiers and the manifest are removed. The definitions table stays in the database and is no longer read.
## Read first: versions, breaking changes, consumers
- **Versions.** The framework target is v0.6.1 with base v0.6.0 (33-01 ruling). `sm-sitemap-plugin/go.mod` still requires `summercms v0.6.0` through the existing `replace => ../summercms.go`, and that replace was not touched. The plugin was at v0.1.2 (049f3ee). These changes are breaking, so the next minor tag is cut in 33-14. Nothing was pushed or tagged.
- **Breaking changes in sm-sitemap-plugin**, approved by the "as-proposed" answer:
1. `/sitemap.xml` is now a `<sitemapindex>`, not a `<urlset>`. The URL is unchanged, so robots.txt and the GSC submission need no change.
2. The files live at the site root as `/sitemap-<slug>-<n>.xml`, served by Go at `/_sitemap/files/<name>`. A site must map the root names: vue-seo-layer does this (33-07, next tag v0.2.0), or nginx can (the README has the snippet).
3. `/sitemap.xsl` answers 404. `<priority>` and `<changefreq>` are no longer written.
4. The admin has one "Sitemaps" tile linking to `golem15.sitemap.settings`. The `golem15.sitemap.definitions` controller and the `sitemap_settings` settings form are gone. The permission code `golem15.sitemap.access_definitions` is unchanged, so roles keep their access.
5. Config keys `golem15.sitemap.cache_seconds` and `golem15.sitemap.priority_tiers` are removed. New keys: `site_url`, `lastmod_ignore`, `rebuild_delay_seconds`. The festival events `classes.BeforeAddItem` and `classes.AddItem` are removed.
6. A `<loc>` never uses the request Host. Without a site address (the setting, `golem15.sitemap.site_url` or `app.url`), the index answers 404.
7. `sitemap:import` writes `{url, lastmod}` into the additional addresses and ignores `changefreq` and `priority`. Its success line is now "Imported N addresses into the sitemap's additional addresses." `sitemap:import-winter` keeps its flags but writes url items into the additional addresses and the settings into `site_url` and `default_locale`. Reference items are dropped.
8. The application needs a conga worker for rebuilds (record events and saves queue them) and the scheduler for the nightly run. Otherwise someone has to run `sitemap:rebuild`. After deploying, run `sitemap:rebuild` once so the files exist.
- **Consumers.** sm-grzybyfunkcjonalne-app and sm-demo-app pin v0.1.2 as a submodule and are untouched. They move to the new URLs in 33-13. No core plugin (user, blog, pages, payment, journal) was changed, and the framework got no code from this plan (D-90; `--marker` passed).
## Performance
- **Duration:** about 43 min
- **Started:** 2026-11-04T09:47:25Z
- **Completed:** 2026-11-04T10:30Z
- **Tasks:** 4 (Task 1 decision answered before the run, Tasks 2 to 4 executed)
- **Commits:** 7 in sm-sitemap-plugin (branch main, not pushed). 78 files changed: 49 added or modified, 29 removed.
## Accomplishments
- **Index and files from a slot table (D-30).** `golem15_sitemap_urls` holds one row per address with its group and file number. Rebuilds keep file numbers, append new addresses to the last file with room, and leave holes for deleted, unpublished or excluded addresses. Only a `file_size` change renumbers. The routes read the table and never generate per request.
- **Real lastmod.** A row's lastmod starts at the record's `LastMod`. After that it moves only when `pact.RecordSaved` reports a change outside `golem15.sitemap.lastmod_ignore`. A translated change moves only that locale's row. A no-op save, a counter update or a rebuild moves nothing. `pact.RecordDeleted` takes the rows out at once.
- **Rebuilds.** Rebuilds run as the conga job `golem15.sitemap.rebuild`:
- debounced after record events through `rebuild_pending_until` (P33-12), defaulting to 60 s;
- dispatched after every settings save commits;
- run nightly at 03:00 through the scheduled `sitemap:rebuild`;
- run by hand with `sitemap:rebuild`, or `--queue` to hand it to a worker.
- **hreflang (D-55 to D-57).** A record with two or more listed locales gets `xhtml:link` alternates on every listed address, plus `x-default` on the default locale's address. The xhtml namespace is declared only when used.
- **Contract (D-95).** `contract.Filter` is asked once per page of records and once for the additional addresses:
- an excluded address keeps its row with the reason and is counted in S1;
- `Decision.Group` moves the address to another file group;
- `Decision.LastMod` sets the first lastmod of a new address.
The plugin publishes `classes.Listing` as `contract.Listing` in `Register`. Its `Settings` returns the site address, the index address, the default locale and the groups with counts and titles. Its `Entries` pages through a group's addresses, with a locale filter.
- **Settings singleton (C26).** One tile opens it. It has three tabs:
- Files: site address, file size, and the S1 partial (index plus files, the left-out table by reason, a note; the empty and building states).
- Content types: built at boot from signpost, one block per type (include, file name, HTML heading).
- Additional addresses.
Saving refuses bad input with the `errors.*` copy.
- **Migration (D-32).** Migration 202610280002 copies the url items of the served definition into `extra_urls`, `frontend_url` into `site_url` and `frontend_default_locale` into `default_locale`. The definitions table and the Phase 17 columns are untouched, and rollback drops only the new columns.
- **Removal.** The generator, the libxml serializer, tiers, the rewrite, the manifest, the `Url::to` port, the events, the PHP goldens with their generator, the XSL, the definition model, the definitions controller and its form are all deleted, along with their tests.
## Task Commits (sm-sitemap-plugin, branch main, not pushed)
1. **Task 1: Confirm the public format.** No commit; answered "as-proposed" (above).
2. **Task 2: index and per-type files (tracer):** `3359f1a` feat: sitemap index and per-type files from a slot table (tracer)
3. **Task 3:**
- group A `3ba70bb` feat: sitemap settings singleton with content types and additional addresses
- group B `84cb27a` feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL
- group C `62fc7d7` feat: migrate definitions into additional addresses
4. **Task 4:**
- group A `653627b` feat: real lastmod from record events and debounced rebuilds
- group B `ded1095` feat: hreflang alternates and the sitemap filter contract
- group C `64453d2` docs: README for the per-type sitemap format
**Plan metadata:** this SUMMARY commit in summercms.go (docs), then ROADMAP alone.
## Files Created/Modified (sm-sitemap-plugin)
- `contract/contract.go`: every P33-08 type. No init and no `party.Register`.
- `classes/slots.go`: `AssignSlots` and `Slot`.
- `classes/rebuild.go`: `Rebuild`, `RebuildResult`, `RebuildArgs` and `RebuildKind`, `DefaultSlug`, `ExtraRecordID`, the Filter decisions, the alternates.
- `classes/writer.go`: `WriteIndex` and `WriteURLSet` on `bufio` with `xml.EscapeText`; `FileName`, `ParseFileName`, `ValidSlug`.
- `classes/settings.go`: `LoadSettings`, `SiteURL`, `Absolute`.
- `classes/types.go`: `EffectiveType`, `LoadTypeSettings`, `ReservedSlugs`.
- `classes/dispatch.go`: `RequestRebuild`, `DispatchRebuild`, `Building`.
- `classes/lastmod.go`: `RecordSaved`, `RecordDeleted`, `RealChanges`, `RequestDebouncedRebuild`, `ClearPending`, and the config readers.
- `classes/listing.go`: `Listing`.
- `models/url.go`, `models/type_setting.go`, `models/legacy.go`: the slot row, the type row, and a reader for Phase 17 and Winter definition data (used by the import and the migration).
- `models/settings.go`: the new columns only, plus `Validate` and `Problems`.
- `updates/2026102800{01,02,03}_*.go`: the slot table, the settings columns with the definitions copy, and the types table.
- `controllers/settings.go` with `settings/config_form.yaml` and `settings/_sitemap_files.htm`: the singleton and S1.
- `extensions_settings.go`: the Content types tab.
- `admin.go`: one tile; embeds by pattern.
- `jobs.go`, `listeners.go`, `schedule.go`, `console/rebuild.go`, `plugin.go`: the job, the listeners, the schedule, the command, and publishing the Listing.
- `console/import.go`, `console/import_winter.go`, `console/helpers.go`: these now write to the additional addresses.
- `routes.go`: the index and file routes.
- `config/config.yaml`: `site_url` (commented), `lastmod_ignore`, `rebuild_delay_seconds`.
- `lang/{en,pl}/lang.yaml`: every C26 key, including the images, News, IndexNow and HTML keys that 33-04 uses, plus `errors.lastmod`, `settings.extra_url_lastmod_comment` and `jobs.rebuild`. The Phase 17 item and definition keys are removed.
- `README.md` and `plugin.yaml` (regenerated by `summer plugin:manifest`), `go.mod` (`goccy/go-yaml` is now a direct dependency; it is already in the decided set).
- Tests:
- `sitemap_index_test.go`: 13 Docker tests.
- `content_fixture_test.go`, `filter_fixture_test.go`: the fake content plugin and the fake filter plugin.
- `import_test.go`, `routes_test.go`, `classes/slots_test.go`, `classes/lastmod_test.go`, `models/settings_test.go`, `console/import_winter_test.go`, `commands_documented_test.go`.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] The framework repeater does not accept a datepicker**
- **Found during:** Task 3
- **Issue:** C26 asks for a datepicker for the row lastmod. cabana refuses `datepicker` inside a `repeater` when it compiles the schema, so the plugin would not boot.
- **Fix:** the lastmod is a text field with the comment "Optional, as YYYY-MM-DD". The server checks it (`models.ValidDate`) and refuses a bad value with the new `errors.lastmod`. This is logged in deferred-items.md and WINDOWS.md #30. The framework fix (datepicker in repeater rows) is separate, additive work.
- **Commit:** 3ba70bb
**2. [Rule 1 - Bug] `BeforeValidate` refusals became 500s**
- **Found during:** Task 3
- **Issue:** cabana's CRUD path turns any `lagoon.HasBeforeValidate` error into a `CapabilityError` (500). The Phase 17 hook only worked through the old settings-form route.
- **Fix:** validation is now `Settings.Validate`, called from the controller's `FormBeforeUpdate` and `FormBeforeCreate` (the SEO plugin's pattern). It returns 422 with field details.
- **Commit:** 3ba70bb
**3. [Rule 3 - Blocking] S1 cannot link to the public file URLs**
- **Issue:** the partial renderer keeps only root-relative `href`s (`safePartialURL`).
- **Fix:** each file links to the backend route that serves it. The note names the public index address. Logged in deferred-items.md as by design.
- **Commit:** 3ba70bb
**4. [Plan shape] Field labels cannot carry `:type`**
- **Issue:** cabana translates field labels without parameters, so "Include :type" cannot be rendered.
- **Fix:** each type's switch is labelled with the type's own signpost label. The copy moved to comments without the parameter (`type_include` "Include this content type in the sitemaps.", `type_slug` "File name", `type_title` "Heading in the HTML sitemap", and their pl equivalents).
- **Commit:** 3ba70bb
**5. [Plan shape] Content type titles stored as a JSONB locale map**
- **Issue:** the plan said "title via the translation writer", but the extension's `Save` already receives the mltext value as a locale map, and `golem15_sitemap_types` has no host-column model for translate.
- **Fix:** titles are stored as `titles jsonb` and fed directly into `GroupInfo.Titles` and `Group.Titles`.
- **Commit:** 3ba70bb
**6. [Rule 3 - Blocking] The Phase 17 tests needed rework**
- **Issue:** the route smoke tests (`TestSitemapTracer`, `TestSitemapCacheAndThrottle`, `TestSitemapImportSmoke`), the admin smoke test (`TestSitemapAdminSmoke`, which already failed before this plan because the framework now localizes 422 phrase keys), `TestBackendURL`, and the tier Boot assertion all pinned the removed format.
- **Fix:** they were replaced by the new tests, and `TestImportWinter` was rewritten for the new targets. No surviving behaviour lost its test.
- **Commits:** 3359f1a, 3ba70bb, 84cb27a
**7. [Plan shape] Extra settings columns**
- The plan did not list `numbered_size` (detects a file size change), `built_at` and `rebuild_requested_at` (the S1 building state).
- `extra_urls` is `JSONB NULL DEFAULT '[]'` instead of `NOT NULL`, because a `lagoon.Jsonable` that is not valid writes NULL.
- **Commit:** 3359f1a
**8. [Acceptance grep] Two greps print more than the plan expects**
- `grep "priority_tiers\|cache_seconds"` still matches `enable_priority_tiers`, in two places:
- the column in the released Phase 17 migration 202610110002, whose DDL must not change;
- the PHP export key that `sitemap:import-winter` recognizes so it can report it as ignored.
Both config keys themselves are gone.
- `grep "/_sitemap/files/{file}" routes.go` prints two lines: the route and its doc comment.
**9. [TDD note] RED evidence**
- Before implementing, I ran the tests for 3C and for 4A and 4B:
- `TestMigrateDefinitions` failed with `extra_urls = ""`.
- `TestLastmod` and `TestDebouncedRebuild` failed on their target assertions.
- `TestHreflangAlternates`, `TestFilterExcludes` and `TestListing` failed on theirs.
- `TestStableNumbering` and `TestNoFilter` passed at RED. They pin invariants that the tracer slot logic already had.
- The plan is `type: execute`, and each task is one commit per the plan's commit-group table, with no separate `test(...)` commit.
### Auth gates
None.
---
**Total deviations:** 9. One is a framework gap (deferred), one is a bug fix, one is a by-design link limitation, and the rest are plan shape, test rework, acceptance-grep notes and a TDD note.
**Impact on plan:** every must-have truth is met. The two visible differences from UI-SPEC C26 are the lastmod text field and the switch labels.
## Known Stubs
- `contract.ListingSettings.HTMLEnabled`, `HTMLPaths`, `HTMLPageSize` and `KeyFile` stay zero, and `golem15_sitemap_urls.images` stays `[]`, until 33-04 adds the HTML sitemap, IndexNow and image settings (P33-08; WINDOWS.md #29). The C26 copy for those tabs is already in the phrasebooks.
## Threat Flags
None outside the plan's threat model. T-33-09 to T-33-12 are mitigated:
- every value goes through `xml.EscapeText`, and the test covers `&`, `<` and quotes;
- only an http(s) site URL from the setting or config is used, never the Host;
- the routes are table-served, throttled and constrained by `Where`;
- only published records are listed, and drafts are tested to be absent;
- `site_url` is validated, and the settings are guarded by the existing permission.
## Issues Encountered
- `go test ./... -run X` prints "no tests to run" for the subpackages, while every named root test passes (same as in 33-02).
## Verification run
- `go vet ./...` and `go test -short ./...` in sm-sitemap-plugin: green after every commit.
- Docker `go test ./... -count=1` in sm-sitemap-plugin: green after every commit except the tracer. At the tracer the old `TestSitemapAdminSmoke` was red; it was already failing before this plan and was removed in 3A.
- The plan's named tests all pass: TestSitemapIndex, TestStableNumbering, TestLastmod, TestHreflangAlternates, TestFilterExcludes, TestNoFilter, TestDebouncedRebuild, TestMigrateDefinitions, TestSettingsSingleton, TestImportWinter. TestListing, TestImportFile and TestRebuildCommand pass too.
- Acceptance greps: `type Filter interface` and `type Listing interface`, `xml.EscapeText`, the files route, no init in `contract/`, no files under assets, `classes/testdata` or `tests/php`, `golem15.sitemap.settings`, `Lookup[contract.Filter]`, `Publish[contract.Listing]`, `sitemap:rebuild` in schedule.go and console/rebuild.go, `x-default`. All present; see deviation 8 for the two greps that print extra lines.
- From summercms.go:
- `bash scripts/check-phase33.sh --plugins --marker`: PASS (sitemap, seo and journal vet and short tests; vitest 77 and 244).
- `bash scripts/check-phase33.sh --docker`: PASS (sitemap 23, seo 56, journal 124 top-level Postgres tests).
## User Setup Required
None for this plan. When the apps move to the new format in 33-13:
- run `sitemap:rebuild` once after the deploy;
- make sure a conga worker and the scheduler run;
- map the root file names (vue-seo-layer v0.2.0 or the nginx rule in the README).
## Next Phase Readiness
- **33-04:** add images, News, IndexNow and the HTML sitemap on this table and settings. Fill the zero `ListingSettings` fields. Use `RebuildResult.Added/Changed/Removed` for IndexNow.
- **33-05:** the SEO plugin implements `contract.Filter` (noindex, canonical, redirect) and may use `Decision.Group` for page-registry kinds.
- **33-13:** move grzybyfunkcjonalne and demo to the new sitemap.
- **33-14:** bump `go.mod` to v0.6.1 and tag the next minor (breaking) version from `git tag`, after v0.1.2. Write the release notes from "Read first".
## Self-Check: PASSED
- FOUND: every file in key-files.created (checked with `[ -f ]`).
- FOUND commits in sm-sitemap-plugin: 3359f1a, 3ba70bb, 84cb27a, 62fc7d7, 653627b, ded1095, 64453d2. `git rev-list --count 049f3ee..HEAD` = 7.
- No uncommitted changes in sm-sitemap-plugin.
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,320 @@
---
phase: 33-seo-output-audit-and-content
plan: 04
subsystem: sitemap
tags: [sm-sitemap-plugin, image-sitemap, news-sitemap, indexnow, fetchguard, html-sitemap, management-api, mcp, settings-singleton]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-03 slot table, rebuild (RebuildResult Added/Changed/Removed), settings singleton, contract.Listing; 33-01 content facts (Kind, PublishedAt, Parents, Images)"
provides:
- "Image sitemap files sitemap-images-<n>.xml (image_file_no, stable numbering, at most 1000 images per page), images_enabled default on (D-31)"
- "Google News sitemap sitemap-news.xml: articles of the last 48 hours, newest first, at most 1000, news_sources type:/category: values, news_enabled default off (D-31)"
- "IndexNow: 32-hex key on save (crypto/rand), indexnow_new_key rotation, GET /_sitemap/indexnow/{file}, job golem15.sitemap.indexnow from rebuild diffs in production only, fetchguard client limited to api.indexnow.org, S2 status partial (D-31)"
- "HTML sitemap API GET /_sitemap/api/v1/html and /_sitemap/api/v1/html/{group} in the contract vue-seo-layer 33-08 consumes (D-34, D-24)"
- "Settings tabs Images and News, IndexNow, HTML sitemap; contract.Listing.Settings fills HTMLEnabled, HTMLPaths, HTMLPageSize, KeyFile"
- "Management API GET/PUT /_sitemap/api/v1/settings, POST /_sitemap/api/v1/rebuild and MCP tools sitemap_get_settings, sitemap_update_settings, sitemap_rebuild (D-10)"
affects: [33-05, 33-07, 33-08, 33-13, 33-14]
actuals:
tokens: 55000
tasks: 3
commits: 6
plan_head_before: 64453d2a914f00a8310f76f806a8858d5d4345da
plan_head_after: 291cf176db68ef498f8b8a57361d2418f7b0ebd0
tech-stack:
added: []
patterns:
- "A second numbering of the same slot rows (image_file_no) with AssignSlots and the same renumber flag as file_no"
- "Outbound vendor call as a conga job through a single-host fetchguard client; the test routes it with fetchguard.WithTransport"
- "Production-only side effect checked twice: before queueing and again in the job"
- "Values a fixed-option taglist cannot offer listed by a server-rendered partial next to it, and checked on save"
key-files:
created:
- ../sm-sitemap-plugin/updates/202610280004_add_golem15_sitemap_media_news_indexnow.go
- ../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/files.go
- ../sm-sitemap-plugin/classes/typesettings.go
- ../sm-sitemap-plugin/controllers/api/indexnow.go
- ../sm-sitemap-plugin/controllers/api/html.go
- ../sm-sitemap-plugin/controllers/api/auth.go
- ../sm-sitemap-plugin/controllers/api/settings.go
- ../sm-sitemap-plugin/controllers/api/logger.go
- ../sm-sitemap-plugin/controllers/settings/_news_sources.htm
- ../sm-sitemap-plugin/controllers/settings/_indexnow_status.htm
- ../sm-sitemap-plugin/mcp.go
- ../sm-sitemap-plugin/sitemap_extras_test.go
modified:
- ../sm-sitemap-plugin/classes/rebuild.go
- ../sm-sitemap-plugin/classes/writer.go
- ../sm-sitemap-plugin/classes/listing.go
- ../sm-sitemap-plugin/classes/settings.go
- ../sm-sitemap-plugin/contract/contract.go
- ../sm-sitemap-plugin/models/url.go
- ../sm-sitemap-plugin/models/settings.go
- ../sm-sitemap-plugin/models/settings/fields.yaml
- ../sm-sitemap-plugin/models/settings_test.go
- ../sm-sitemap-plugin/controllers/settings.go
- ../sm-sitemap-plugin/extensions_settings.go
- ../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/README.md
- ../sm-sitemap-plugin/plugin.yaml
- ../sm-sitemap-plugin/content_fixture_test.go
- ../sm-sitemap-plugin/sitemap_smoke_test.go
- ../sm-sitemap-plugin/commands_documented_test.go
- .planning/phases/33-seo-output-audit-and-content/COVERAGE.md
key-decisions:
- "HTML sitemap API contract follows what vue-seo-layer 33-08 already reads: overview {groups:[{key, slug, title, total, more, entries:[{title, url, children?}]}]}, group page {group, title, page, pages, entries}; groups are addressed by their file slug; locale and page and page_size and total are extra fields"
- "html_path is stored as a JSONB locale map (html_paths) through the plugin's own form extension, not the translation writer (same reason as 33-03's type titles: the settings row has no translatable host model); a save merges the locales sent"
- "news_sources is a taglist with custom tags and no options; the values (type:<key>, category:<key>:<id>) are listed by the news_sources partial and checked on save, because cabana taglist options are a fixed YAML list and form extensions take no taglist"
- "IndexNow 5xx answers and network errors retry like 429 (five attempts); 400, 403 and 422 are recorded without retry; S2's err_rate time is the queued retry's scheduled_at in river_job, shown only while one is pending"
- "Image sitemap files use the same file size and renumber flag as the per-type files"
- "S1 and the index share classes.SitemapFiles, so the files table now lists the image and News files too (the index count counts addresses only once)"
- "The content type settings write moved to classes.SaveTypeSettings, shared by the form extension and PUT /_sitemap/api/v1/settings (types.<key>.<field> refusals)"
patterns-established:
- "Plugin management API mirrors the SEO plugin's manage wrapper: cabana.ResolveBackend (token cover golem15.sitemap.*) plus cabana.Allows, 422 validation_failed with translated messages, X-Robots-Tag noindex"
- "A compile-only pier.Mount in the plugin's tests proves the MCP declarations bind to registered routes"
requirements-completed: [SC-1, D-31, D-34, D-24, D-10, D-30]
coverage:
- id: D1
description: "Image sitemap: featured then body images, deduplicated, unsafe dropped, absolute, at most 1000 per page, only image:image/image:loc, stable numbering with holes and renumber on file size change, images_enabled off removes the files from the index and answers 404"
requirement: "D-31"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestImageSitemap"
status: pass
human_judgment: false
- id: D2
description: "News sitemap: 1 h and 47 h old articles listed, 49 h not, never another kind, publication name (news_publication else app.name), language, date, escaped title, type and category sources, newest 1000 of 1007, off by default (no index entry, 404), malformed source refused, partial lists the values"
requirement: "D-31"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestNewsSitemap"
status: pass
human_judgment: false
- id: D3
description: "IndexNow in production: key on save (32 hex), key file text/plain, other names 404, one job with the new and the removed address, POST {host, key, urlList} to api.indexnow.org without keyLocation, 202 recorded, 403/422/400 recorded without retry, 429 retries, S2 states and callouts, other hosts never sent, new key makes the old file 404, off takes the file down"
requirement: "D-31"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestIndexNow"
status: pass
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestIndexNowStaging"
status: pass
human_judgment: false
- id: D4
description: "HTML sitemap API: L3 group order, titles in the locale, category tree, posts newest first, others alphabetical, 150 + more, page 2 of 2, 404 past the last / malformed page / unknown group / off, excluded and other-locale addresses absent, noindex; HTML tab refusals; Listing HTML and KeyFile fields"
requirement: "D-34"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestHTMLGroups"
status: pass
human_judgment: false
- id: D5
description: "Management API and MCP: 401/403, GET body, PUT refusals store nothing, PUT stores settings, HTML paths merge, types, IndexNow key and rotation, rebuild queued; MCP dispatch as the linked administrator; pier compiles sitemap_get_settings, sitemap_rebuild, sitemap_update_settings"
requirement: "D-10"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestSitemapTools"
status: pass
human_judgment: false
- id: D6
description: "The new settings tabs (Images and News, IndexNow with S2, HTML sitemap) in the real admin: field layout, triggers hiding the News fields and the new-key checkbox, the partial tables and callouts"
verification: []
human_judgment: true
rationale: "Rendering in the embedded admin SPA; the tests cover the schema, the saves and the partial node trees, not the visual layout"
- id: D7
description: "A HTML-sitemap group larger than html_page_size shows its first page plus the sitemap.all link and its group page is paginated (layer rendering in 33-08)"
requirement: "D-34"
verification:
- kind: integration
ref: "../sm-sitemap-plugin/sitemap_extras_test.go#TestHTMLGroups"
status: pass
human_judgment: false
duration: 30min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 04: Sitemap extras Summary
**sm-sitemap-plugin now adds image sitemap files and a Google News sitemap to the index, notifies IndexNow of each rebuild's changes in production through a single-host fetchguard client, serves the HTML sitemap's data in the JSON contract vue-seo-layer already reads, and exposes all sitemap settings through a management API that is also the MCP tools sitemap_get_settings, sitemap_update_settings and sitemap_rebuild.**
## Read first: versions, breaking changes, consumers
- **Versions.** Framework target v0.6.1 on base v0.6.0 (33-01 ruling). `sm-sitemap-plugin/go.mod` still requires `summercms v0.6.0` with `replace => ../summercms.go`; the bump is 33-14's. The plugin stays at v0.1.2 plus the 7 unpushed 33-03 commits plus these 6. Nothing was pushed or tagged; the breaking next-minor tag is 33-14's.
- **No new breaking change.** Everything here is additive to the 33-03 format: new columns with defaults (migration `202610280004`), new routes, new settings, a second job kind. The image sitemap is on by default, so after the next rebuild the index of a site with images gains `sitemap-images-<n>.xml` entries; News and IndexNow are off by default; the HTML sitemap API answers by default (the layer only renders it while the site answer has it on with a path).
- **For consumers (33-13, 33-14).** The application needs the conga worker for the IndexNow job as well. A site that turns IndexNow on must serve `/<key>.txt` at the root: vue-seo-layer does it from the SEO plugin's `root_files` (which should include `contract.ListingSettings.KeyFile`, 33-05's part), nginx with the README snippet. No core plugin (user, blog, pages, payment, journal) was touched, and the framework got no code (D-90; `--marker` passed).
## Performance
- **Duration:** about 30 min
- **Started:** 2026-11-04T11:14:58Z
- **Completed:** 2026-11-04T11:45Z
- **Tasks:** 3
- **Commits:** 6 in sm-sitemap-plugin (main, not pushed), 1 planning-docs commit in summercms.go (COVERAGE.md)
## Accomplishments
- **Image sitemap (D-31).** The rebuild stores each page's featured image and body images (absolute on the site, each once, at most 1000; `data:` and `javascript:` dropped) and numbers the listed rows with images into `sitemap-images-<n>.xml` with the per-type rules: stable numbers, holes, renumbering only with the file size. The files carry only `image:image` and `image:loc`.
- **News sitemap (D-31).** `sitemap-news.xml` lists articles (content kind `article`) first published in the last 48 hours, newest first, at most 1000, with the publication name, language, date and title. `news_sources` narrows it to `type:<key>` or `category:<key>:<id>` values.
- **IndexNow (D-31).**
- Saving with IndexNow on generates the key; "Generate a new key on save" replaces it and the old key file answers 404 at once.
- After each committed rebuild, in production only, the added, changed and removed addresses on the site host are queued as `golem15.sitemap.indexnow` jobs of at most 10 000.
- The job posts `{host, key, urlList}` to `https://api.indexnow.org/indexnow` through a fetchguard client limited to that host, records every answer, and retries 429, 5xx and network errors (five attempts).
- S2 shows the key or `no_key`, the last notification or `never`, and the 403, 422 and 429 callouts.
- **HTML sitemap API (D-34, D-24).** The overview lists the non-empty groups in the L3 order with titles in the locale, the category tree, posts newest first and the rest alphabetical, each with the first `html_page_size` links and `more`. A group page pages the rest. Only the XML sitemaps' listed addresses appear. Public, `noindex`, bucket `sitemap-html`.
- **Settings tabs.** Images and News, IndexNow (with S2) and HTML sitemap, with the C26 copy and the `errors.html_path` and `errors.page_size` refusals. `contract.Listing.Settings` now answers `HTMLEnabled`, `HTMLPaths`, `HTMLPageSize` and `KeyFile`.
- **Management API and MCP (D-10).** GET and PUT settings and POST rebuild behind the sitemap permission, and the three tools. pier compiles them against the plugin's routes in the test.
## Task Commits (sm-sitemap-plugin, branch main, not pushed)
1. **Task 1 (tracer):** `5bf56fd` feat: image sitemap files (tracer)
2. **Task 2:**
- group A `a10ce2d` feat: Google News sitemap
- group B `e02d0b1` feat: IndexNow key, key file and pings
- group C (summercms.go) `01a316a` docs(33-04): update the IndexNow coverage matrix
3. **Task 3:**
- group A `8f4ff2a` feat: HTML sitemap groups API
- group B `2a05516` feat: sitemap management API and MCP tools
- group C `291cf17` docs: images, News, IndexNow, HTML sitemap and MCP in README
**Plan metadata:** this SUMMARY commit in summercms.go (docs), then ROADMAP alone.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed and plan-shape deviations
**1. [Plan shape] HTML sitemap addresses stored as a JSONB locale map**
- **Issue:** P33-13 says `html_path` goes through the translation writer. The settings row has no translatable host model, and without a writer cabana refuses a translated value on the main form.
- **Fix:** column `html_paths jsonb` (default pl `/mapa-strony`, en `/sitemap`), edited as the mltext `html_path` added by the plugin's own form extension after `html_enabled`. A save merges the locales sent. Same approach as 33-03's type titles.
- **Commit:** 8f4ff2a
**2. [Rule 3 - Blocking] News sources taglist without options**
- **Issue:** C26 asks for `customTags: false` with options from the article types and their categories. cabana taglist options are a fixed YAML list, and form extensions accept no taglist field, so dynamic options are impossible.
- **Fix:** the taglist accepts custom tags. A `news_sources` partial under it lists the values the site has (article types and the categories their articles name, `classes.NewsSources`). Each value is checked on save (`models.ValidNewsSource`, new `errors.news_source`). The API answers the values as `news_source_options`. Logged in deferred-items.md and WINDOWS.md #31.
- **Commit:** a10ce2d
**3. [Rule 2 - Correctness] IndexNow 5xx and network errors retry**
- P33-14 names 429 only. A 5xx or a network failure says nothing about the request, so dropping the notification would lose the change. These are retried within the five attempts as well. COVERAGE.md gained that row and the precise key file route (group C).
- **Commit:** e02d0b1, 01a316a
**4. [Plan shape] S2's 429 time**
- `err_rate` needs ":time" of the next attempt. The queue owns the retry schedule, so S2 reads the pending job's `scheduled_at` from `river_job` and shows the callout only while a retry is queued. No extra column.
- **Commit:** e02d0b1
**5. [Plan shape] The key file route, not a root route**
- As planned (`/_sitemap/indexnow/{file}`); no `/<key>.txt` root route in Go, so a site served at `/` keeps its one-segment pages. vue-seo-layer maps the root name from the site answer's `root_files`.
**6. [Rule 2 - Correctness] S1 and the index share one file listing**
- With image and News files in the index, S1 would otherwise have hidden them. `classes.SitemapFiles` now feeds both, and the index row of S1 counts addresses once.
- **Commit:** 2a05516
**7. [Rule 3 - Blocking] Type settings write shared with the API**
- `PUT /_sitemap/api/v1/settings` must check content types like the form. The 33-03 save logic moved unchanged to `classes.SaveTypeSettings` (field-name refusals; the API renames them `types.<key>.<field>`).
- **Commit:** 2a05516
**8. [Rule 3 - Blocking] Test adjustments**
- `TestScheduleAndJobs` now expects two jobs.
- `TestSettingsValidate` builds settings with the default HTML page size, because 0 is now refused.
- `bootPlugins` takes an `__env` override, so `TestIndexNow` runs in production.
- The fake content record carries `PublishedAt` and `Parents`.
- A fake blog plugin (`acme.journal`, an article type and a collection type) is booted only by the new tests, so the 33-03 tests are unchanged.
**9. [Plan shape] Copy additions**
- New phrases outside C26:
- `errors.news_publication`, `errors.news_source`, `errors.type_unknown`;
- `news.sources`, `news.source_value`, `news.source_label`, `news.source_type`, `news.none`.
- Every C26 key is used as written.
**10. [TDD note] RED evidence**
- The plan is `type: execute`, and each task is one commit per its commit-group table.
- Each test was written against the behaviour list before its implementation compiled. The tests of a group were run once the group compiled, and passed on that first run.
- No separate RED commit was made.
### Auth gates
None.
---
**Total deviations:** 10. One is a framework gap (deferred: taglist options), one is storage shape, two are correctness additions, one is a status-source choice, and the rest are refactors, tests and copy.
**Impact on plan:** every must-have truth is met. The visible difference from UI-SPEC C26 is the News sources tag list, which accepts typed values with the value table under it instead of a fixed choice.
## Known Stubs
None. WINDOWS.md #29 (the zero ListingSettings fields and the empty images column from 33-03) is resolved here and marked fixed.
## Threat Flags
None outside the plan's threat model:
- **T-33-13:** fetchguard AllowHostsMode with `api.indexnow.org` only, HTTPS, no redirects, a 10 s timeout. `classes.SiteURLs` keeps only addresses on the site host, and the test sends a foreign host and a look-alike host.
- **T-33-15:** a production check before queueing and again in the job (`TestIndexNowStaging`).
- **T-33-16:** paging capped at `html_page_size` (at most 500), the tree read capped at 10 000 rows, bucket `sitemap-html`.
- **T-33-17:** `cabana.ResolveBackend` and the permission check on every management route; the 401 and 403 cases are in `TestSitemapTools`.
- **T-33-14** (key disclosure) is accepted as planned.
- **New public surface, in the plan:** `GET /_sitemap/indexnow/{file}` and the two HTML routes; they serve only data the sitemaps already publish.
## Issues Encountered
- `go test ./... -run X` prints "no tests to run" for the subpackages while the named root tests pass (as in 33-02 and 33-03).
- The 33-09 executor worked in sm-journal-plugin at the same time. `--plugins` still passed with its working tree as found.
## Verification run
- sm-sitemap-plugin, after every commit:
- `go vet ./...` and `go test -short ./...`: green.
- Docker `go test ./... -count=1`: green.
- The plan's named tests all pass: TestImageSitemap, TestSitemapIndex, TestNewsSitemap, TestIndexNow (plus TestIndexNowStaging), TestHTMLGroups, TestSitemapTools. The 33-03 suite passes too.
- Acceptance greps:
- one line each for `sitemap-image/1.1` (writer.go), `image_file_no` (migration), `crypto/rand` and `AllowHostsMode` (indexnow.go), and `sitemap-news/0.9` (news.go);
- `/_sitemap/api/v1/html` appears in routes.go and README.md;
- `"get_settings"`, `"update_settings"` and `"rebuild"` appear in mcp.go;
- `X-Robots-Tag` appears in html.go;
- COVERAGE.md has 3 OPT-OUT rows, each with a reason.
- From summercms.go:
- `bash scripts/check-phase33.sh --plugins --marker`: PASS (sitemap, seo and journal vet and short tests; vitest 77 and 244).
- `bash scripts/check-phase33.sh --docker`: PASS (29 sitemap, 56 seo and 124 journal top-level Postgres tests).
- `--layers` was not run: it is known to be broken and is logged in deferred-items.md.
## User Setup Required
None for this plan. When a site turns IndexNow on (after 33-13):
- it must run in the `production` environment with a conga worker;
- the key file must be reachable at `https://<site>/<key>.txt` (vue-seo-layer from the site answer, or the nginx rule in the README).
## Next Phase Readiness
- **33-05:** the SEO plugin's site answer can take `html_sitemap` (enabled, paths, page_size) and the key file for `root_files` from `contract.Listing.Settings`.
- **33-13:** consumers move to the new sitemap and may enable News or IndexNow per site.
- **33-14:** bump `go.mod` to v0.6.1 and tag the next minor of sm-sitemap-plugin, covering 33-03 and 33-04.
## Self-Check: PASSED
- FOUND: every file in key-files.created (checked with `[ -f ]`).
- FOUND commits in sm-sitemap-plugin: 5bf56fd, a10ce2d, e02d0b1, 8f4ff2a, 2a05516, 291cf17. `git rev-list --count 64453d2..HEAD` = 6.
- FOUND commit in summercms.go: 01a316a.
- No uncommitted changes in sm-sitemap-plugin.
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,447 @@
---
phase: 33-seo-output-audit-and-content
plan: 05
subsystem: seo
tags: [sm-seo-plugin, sitemap-filter, page-registry, route-report, site-answer, llms-txt, robots-txt, links, outbound, site-cache-purge, mcp]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-03/33-04 sm-sitemap-plugin contract (Filter, Listing with HTML settings and KeyFile); 33-01 descriptor Kind/Parents; 33-08 layer contracts for POST /registry/routes and GET /site"
provides:
- "classes.SitemapFilter published as contract.Filter: noindex, canonical-elsewhere and redirected addresses leave the sitemaps; production values, so staging lists the same set (D-95, D-24)"
- "Page registry golem15_seo_pages: SEO › Pages screen, content type seo-page (SEO tab, resolve ok, sitemaps by kind), C21 refusals (D-33)"
- "POST /_seo/api/v1/registry/routes route report: token, 2000-path cap, insert-only, missing state (D-33, P33-20)"
- "GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and MCP tools seo_list_pages, seo_create_page, seo_update_page, seo_delete_page (D-10)"
- "GET /_seo/api/v1/site (P33-17) and GET /llms.txt (D-25)"
- "robots.txt Sitemap: line from the sitemap plugin's index unless robots_sitemaps is set (D-22)"
- "Links tab (links_new_tab, nofollow_domains, sponsored_domains) and outbound in the resolve answer (P33-16, D-83)"
- "noindex_published only for listed addresses; CMS issue sitemap_blocked; P1 status.excluded_* callouts (D-12, D-24)"
- "Site-cache purge after every SEO write, once per transaction (P33-19)"
- "sm-sitemap-plugin: contract.ListingSettings.TypesOff (additive)"
affects: [33-06, 33-07, 33-08, 33-10, 33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 60200
tasks: 3
commits: 9
plan_head_before: 55f5e5dbb0c1fc50851dd51c9a8f258d265d6024
plan_head_after: df93c860011ee33158657a27f1a82ea1eeef6595
tech-stack:
added: []
patterns:
- "A plugin's own form extension stores a translatable field as a locale map in JSONB (registry url/title), so the record can be matched by any locale's address without the translation writer"
- "Content-type label that is not a phrase key is shown as written (no translator lookup)"
- "Site-cache purge deduplicated per transaction by the transaction's ConnPool (package-level map, reset when it grows past 1024 entries)"
- "Listener transactions that record history run in lagoon.Transaction, so their after-commit work runs"
key-files:
created:
- ../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/models/page.go
- ../sm-seo-plugin/models/page/columns.yaml
- ../sm-seo-plugin/models/page/fields.yaml
- ../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/menu_item_types.go
- ../sm-seo-plugin/extensions_pages.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/sitemap_filter_test.go
- ../sm-seo-plugin/registry_test.go
modified:
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.work
- ../sm-seo-plugin/plugin.go
- ../sm-seo-plugin/plugin.yaml
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/extensions_robots.go
- ../sm-seo-plugin/listeners.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/classes/resolver.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/robots_write.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/generate.go
- ../sm-seo-plugin/classes/undo.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/models/robots.go
- ../sm-seo-plugin/models/revision.go
- ../sm-seo-plugin/extension/_seo_status.htm
- ../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/settings_test.go
- ../sm-seo-plugin/robots_test.go
- ../sm-seo-plugin/batch_test.go
- ../sm-seo-plugin/operations_test.go
- ../sm-sitemap-plugin/contract/contract.go
- ../sm-sitemap-plugin/classes/listing.go
- ../sm-sitemap-plugin/sitemap_index_test.go
- ../sm-sitemap-plugin/README.md
key-decisions:
- "The sitemap filter also excludes an address the site's address rules would 301 (normalisation) with reason redirect, not only redirect-rule sources"
- "Registry addresses and names per locale are stored in urls/titles JSONB by the plugin's own form extension (fields url and title), not through the translation writer, so Match can find a page by any locale's address in one query"
- "Registry content type label is golem15.seo::lang.pages.type (Site page / Strona witryny): the plain Page label collided with another content type's SEO settings tab"
- "Plan commit groups 2A and 2C are one commit: TestEveryAdminOperationHasTool maps every Pages operation to its tool, so the controller cannot land without the tools"
- "Migration 202610280003 (links and llms columns) landed with the Links commit, ahead of the llms.txt commit that uses its robots columns"
- "llms.txt and the route report: llms.txt answers 404 outside production (non-production lock), and the route report answers 200 with {inserted, updated, skipped, reported_at}"
- "robots.txt names contract.ListingSettings.IndexURL (the sitemap plugin's site address) when robots_sitemaps is unset; the config default became a comment; both consumer apps set robots_sitemaps explicitly and keep their lines"
- "Issue gained an optional severity field, set to error for sitemap_blocked only; the site-audit plans fill the others"
- "sm-sitemap-plugin got the additive contract.ListingSettings.TypesOff so P1 can say status.excluded_type without reading the sitemap's tables (D-95)"
patterns-established:
- "Registry/content-type ownership check: iterate signpost content types except the own one and call Match; Exact means owned"
- "A management write of a content-type record fires cabana.EmitRecordSaved/EmitRecordDeleted itself, so SEO address tracking and the sitemap follow as for an admin save"
requirements-completed: [SC-1, SC-3, D-95, D-24, D-33, D-12, D-22, D-25, D-83, D-94, D-10]
coverage:
- id: D1
description: "A noindex page leaves the XML sitemap with reason noindex, in production and on staging alike"
requirement: "D-95"
verification:
- kind: integration
ref: "../sm-seo-plugin/sitemap_filter_test.go#TestSitemapFilterTracer"
status: pass
human_judgment: false
- id: D2
description: "Canonical elsewhere, redirect and rule-noindex reasons; P1 excluded callouts (plain) for noindex, canonical, redirect and a type switched off"
requirement: "D-24"
verification:
- kind: integration
ref: "../sm-seo-plugin/sitemap_filter_test.go#TestFilterReasons"
status: pass
human_judgment: false
- id: D3
description: "Registry: create, resolve ok, seo-page record with SEO tab, sitemap-pages and sitemap-offers files, hreflang, C21 refusals (owned, taken, invalid, required, parent loop), 301 on address change (admin and API), filter groups and titles, delete removes entries and the sitemap row, management API with 401/403"
requirement: "D-33"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestRegistry"
status: pass
human_judgment: false
- id: D4
description: "Route report: 404 without a token, notfound_token fallback, 401, 422 over 2000 paths, insert-only with derived title, owned and invalid paths skipped, a repeated report changes only reported_at and writes no history, missing row state, P9 states, missing filter"
requirement: "D-33"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestRouteReport"
status: pass
human_judgment: false
- id: D5
description: "Site answer keys, root files (verification and IndexNow key), routes without the registry, noindex header"
requirement: "D-97"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestSiteEndpoint"
status: pass
human_judgment: false
- id: D6
description: "llms.txt off 404, on text/plain with site name, intro, groups and escaped links, excluded and other-locale addresses absent, staging 404"
requirement: "D-25"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestLLMSTxt"
status: pass
human_judgment: false
- id: D7
description: "robots.txt Sitemap line from the index, or the configured robots_sitemaps"
requirement: "D-22"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestRobotsSitemapLine"
status: pass
- kind: integration
ref: "../sm-seo-plugin/robots_test.go#TestRobotsServed"
status: pass
human_judgment: false
- id: D8
description: "Links tab: defaults, line-numbered domain refusal (admin and API), normalised storage, MCP-writable keys, outbound in resolve"
requirement: "D-83"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestSettingsLinks"
status: pass
human_judgment: false
- id: D9
description: "noindex_published only for listed addresses; sitemap_blocked issue with line and rule"
requirement: "D-12"
verification:
- kind: integration
ref: "../sm-seo-plugin/sitemap_filter_test.go#TestNoindexPublishedConflict"
status: pass
- kind: integration
ref: "../sm-seo-plugin/sitemap_filter_test.go#TestSitemapBlockedIssue"
status: pass
human_judgment: false
- id: D10
description: "One purge per committed SEO write (SEO tab, MCP redirect, settings, applied batch); none for a dry run or a refused batch"
requirement: "D-97"
verification:
- kind: integration
ref: "../sm-seo-plugin/registry_test.go#TestPurgeAfterWrite"
status: pass
human_judgment: false
- id: D11
description: "Every Pages operation maps to an MCP tool bound to a route"
requirement: "D-10"
verification:
- kind: unit
ref: "../sm-seo-plugin/operations_test.go#TestEveryAdminOperationHasTool"
status: pass
human_judgment: false
- id: D12
description: "Pages screen, Links tab and the llms.txt fields in the real admin SPA: layout, trigger hiding llms_intro, parent dropdown, missing badge"
verification: []
human_judgment: true
rationale: "Rendering in the embedded admin SPA; the tests cover schema, saves, row states and partial node trees, not the visual layout"
duration: 70min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 05: SEO plugin sitemap filter, page registry and site-level endpoints Summary
**sm-seo-plugin now decides what the sitemaps list (noindex, canonicalised and redirected addresses leave them), keeps a page registry of the site's own frontend pages as the content type seo-page with an insert-only route report, and gives the layer its site answer, llms.txt, the index Sitemap: line, Links rules in the resolve answer and a site-cache purge after every SEO write.**
## Read first: versions, breaking changes, consumers
- **Versions.** Framework v0.6.0 base, v0.6.1 target (33-01 ruling); the framework got no code here (D-90, `--marker` passed). `sm-seo-plugin` stays at tag v0.1.1 plus these 9 unpushed commits on main; `sm-sitemap-plugin` at v0.1.2 plus 14 unpushed commits (13 from 33-03/33-04, 1 here). Nothing was pushed or tagged.
- **go.mod.** `sm-seo-plugin/go.mod` requires `git.golem15.com/golem15/sm-sitemap-plugin v0.1.2`, and `go.work` replaces it with `../sm-sitemap-plugin`. The v0.1.2 tag has no `contract` package, so a build with `GOWORK=off` fails until 33-14 bumps the require to the new sitemap tag. Consumers resolve the sitemap through their own submodule `use`/`replace` (both apps already do).
- **Changes a consumer will notice when it moves to the next sm-seo-plugin (33-13/33-14):**
- **`Requires()` now names `golem15.sitemap`.** A site with the SEO plugin must activate the sitemap plugin. Both known consumers (sm-demo-app, sm-grzybyfunkcjonalne-app) already list both plugins. Because no existing consumer breaks, I did not stop for a checkpoint.
- The SEO settings get a **Site page** tab (the registry's per-type settings) and a **Links** tab; robots.txt gets the llms.txt fields; SEO › Pages appears in the side menu.
- **robots.txt.** It names the sitemap plugin's index URL when `golem15.seo.robots_sitemaps` is unset. Both consumer apps set that key in `config/golem15.seo.yaml`, so their lines stay as they are.
- **noindex_published.** This batch conflict now fires only for addresses a sitemap file lists right now.
- The resolve answer gains the `outbound` key; no existing key changed.
- **New tables and columns.** Migrations `202610280002` and `202610280003` add the `golem15_seo_pages` table and new columns on settings and robots, all with defaults.
- **Core plugins.** Untouched: user, blog, pages, payment and journal. The vue-* layers were not changed either.
## Performance
- **Duration:** about 70 min (11:40Z to 12:51Z)
- **Tasks:** 3 (tracer, then 2 TDD tasks)
- **Commits:** 9 in sm-seo-plugin, 1 in sm-sitemap-plugin (main, not pushed); planning docs in summercms.go
## Accomplishments
- **Sitemap filter (D-95, D-24).** `classes.SitemapFilter` is published in `Register`. It decides on production values, through a `Resolver` flag that skips the non-production lock. For each address it checks, in order:
- normalisation or a redirect rule → `redirect`;
- effective robots noindex → `noindex`;
- canonical URL not the page itself → `canonical`.
Registry pages go into the `pages`, `offers`, `services` and `landing-pages` groups. `Groups` titles each group per locale.
- **Page registry (D-33, C21).**
- **Model and migration:** the `golem15_seo_pages` model and its migration.
- **SEO › Pages screen:**
- the list has kind, origin and missing filters and the missing row state;
- the form has the P9 partial, and the url/title mltext extension saves each locale and refuses `url_required`, `url_invalid`, `url_taken` and `url_owned` (with type and title), plus `parent_loop`.
- **Content type `seo-page`:** Records, Describe, Match by any locale, Resolve and References. Each page gets the SEO tab, resolves ok, takes part in address tracking with a 301 on change, and is listed in the sitemaps.
- **Route report (P33-20).** `POST /registry/routes` uses a constant-time token and accepts at most 2000 paths. It only inserts or touches `reported_at`, skips owned and invalid paths, and stamps `routes_reported_at`.
- **Management API and MCP (D-10).** Pages CRUD, the four tools and the `AdminOperations` rows.
- **Site answer (P33-17).** Every key the layer's fixture expects except `pagination` and `home_crumb`, which 33-06 adds.
- **llms.txt (D-25).** Built from the sitemaps' listed addresses in the default locale, grouped in the content types' order, with Markdown escaping.
- **robots.txt (D-22).** One `Sitemap:` line naming the index.
- **Links (D-83).** The Links tab, domain validation with line numbers, normalised storage, `outbound` in the resolve answer, and MCP keys.
- **Conflicts and issues (D-12, D-24).** `noindex_published` only for listed addresses. New issue `sitemap_blocked`: severity error, `seo_update_robots`, the line and the rule. P1 shows the "Not in the sitemaps" callouts.
- **Purge (P33-19).** `Service.Record` queues `cooler.PurgeSiteCacheAfterCommit` (kind `seo`) once per transaction.
## Task Commits
sm-seo-plugin (branch main, not pushed):
1. **Task 1 (tracer):** `17b810d` feat: sitemap filter excludes noindex, canonicalised and redirected URLs (tracer)
2. **Task 2:**
- `9e802ec` feat: page registry with its admin screen and signpost type (groups A and C together)
- `26193ae` feat: route report endpoint (group B)
3. **Task 3:**
- `49d3879` feat: links settings and outbound rules in resolve (group B)
- `742a5b9` feat: site endpoint, llms.txt and the sitemap index in robots.txt (group A)
- `59f0457` feat: sitemap-aware batch conflict and the sitemap_blocked issue (group C)
- `2dfc606` feat: purge the site cache after SEO writes (group D)
- `397c126` fix: name a content type's plain label in url_owned without translating it
- `df93c86` docs: registry, site endpoint, llms.txt and links in README (group E)
sm-sitemap-plugin (branch main, not pushed):
- `9e5c393` feat: the listing names the content types switched off
The tracer gate ran in interactive end-of-phase mode: `TestSitemapFilterTracer` was re-run and passed before the expansion tasks.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Adapted to the actual repositories
**1. [Plan shape] Registry translations in JSONB, not the translation writer.**
- **Plan:** P33-18 puts other locales "through the translation writer".
- **Why it cannot work:** a signpost `Match` must find a page by its pl address. The writer offers no reverse lookup, and the test fixture's fake writer is not keyed per record.
- **What I did:**
- `urls` and `titles` are JSONB locale maps (GIN index on `urls`), edited as the mltext fields `url` and `title` of the plugin's own form extension. This is the same approach as 33-03 and 33-04.
- That extension runs before the SEO tab's, so address tracking sees the saved address.
- The url index is unique where `url <> ''`, because the owner row is inserted before the extension writes the address.
- **Commit:** 9e802ec
**2. [Rule 3 - Blocking] Registry type label.**
- **Problem:** the registry type's label `pages.name` ("Page") collided with another content type's "Page" tab in the SEO settings, and cabana refuses two tabs whose labels read the same.
- **Fix:** new copy key `pages.type` (Site page / Strona witryny).
- **Commit:** 9e802ec
**3. [Rule 3 - Blocking] Field types cabana requires.**
- `content_updated_at` is a `*lagoon.Date`: cabana's datepicker in date mode needs that type.
- `parent_id` is `*models.PageRef`, which has `UnmarshalText`, because the dropdown posts text.
- **Commit:** 9e802ec
**4. [Rule 1 - Bug] Row state read from the table.**
- **Bug:** `ListExtendRecords` replaces the origin with its label before `ListRowStates` runs, so the missing state could never match.
- **Fix:** `ListRowStates` reads the state with `models.MissingSQL`.
- **Commit:** 9e802ec
**5. [Plan shape] Commit groups changed.**
- Task 2's groups A and C are one commit, because the parity test needs the Pages tools and the Pages controller together.
- Task 3 order is B (Links, with migration 202610280003, which also holds the llms columns), A, C, D, E.
- Task 2's route report (B) was split from A by staging stripped copies of the files. Both commits passed vet and the Docker suite.
**6. [Rule 2 - Correctness] Additive sitemap contract field.**
- **Why:** P1's `status.excluded_type` needs to know which content types are switched off, and `contract.Listing` had no way to say so. D-95 forbids reading the sitemap's tables.
- **What I did:** added `ListingSettings.TypesOff` in sm-sitemap-plugin, with a test assertion and a README line. The change is additive and outside the plan's file list.
- **Commit:** 9e5c393 in sm-sitemap-plugin
**7. [Rule 2 - Correctness] What the filter treats as a redirect.**
- **Rule:** an address the site's address rules would 301 (normalisation) is excluded as `redirect`, as well as redirect-rule sources.
- **Why:** a listed URL that 301s is a sitemap error.
**8. [Rule 2 - Correctness] Changes to existing code paths.**
- **llms.txt** answers 404 outside production (the non-production lock).
- **Undo** handles the new robots fields `llms_enabled` and `llms_intro`.
- **Transactions:** `RecordDeleted` and `addressesAfterSave` now run in `lagoon.Transaction`. Their history records would otherwise hit "after-commit callback skipped inside unmanaged transaction" and never purge.
- **robots.txt checks:** the file is composed once per batch of address checks, not once per address.
- **Commits:** 742a5b9, 2dfc606, 59f0457
**9. [Plan shape] Copy additions.**
- `pages.type` and `pages.home_title` (the name of a reported `/`: Home / Strona główna).
- `pages.filter_missing_only` and `pages.filter_missing_hide`, read from the C21 shorthand.
- `history.what_page`, `history.kind_page`.
- `Issue.severity` (JSON `severity`) is set only for `sitemap_blocked`.
**10. [Rule 3 - Blocking] Existing tests adjusted to the planned behaviour.**
- `TestSettingsTypeTabs`: the Site page and Links tabs.
- `TestRobotsServed` and `TestRobotsScreen`: the Sitemap line now names the sitemap's index, which is on app.url in the fixture, and the llms fields are on the screen.
- `TestBatch`: rebuilds the sitemap first, so its address is listed.
- `TestPluginManifest`: plugin.yaml regenerated with `requires: golem15.sitemap`.
- The fixture boots `golem15.sitemap` whenever `golem15.seo` is booted.
**11. [Rule 1 - Bug] Plain content type labels.**
- **Bug:** `url_owned` passed a plain content type label to the translator, which logged it as a missing key.
- **Fix:** labels without `::` are used as written.
- **Commit:** 397c126
### TDD note
The plan is `type: execute`. Each task's tests were written before or alongside its code. They were run once the group compiled, and the failures fixed before the group's commit:
- the "/pl/pages/first" substring;
- the file group name;
- the NOT NULL urls column;
- the datepicker type;
- the dropdown fill;
- the row-state ordering.
No separate RED commit was made: the plan requires green tests at every commit, as in 33-04.
### Auth gates
None.
**Total deviations:** 11. Two are storage or contract shapes, four are blocking fixes, three are correctness additions, one is commit grouping and one is copy additions.
**Impact on plan:** every must-have truth is met. The visible additions to UI-SPEC are the Site page settings tab label and the plain-callout variant of P1.
## Issues Encountered
- **Shared file group.** A content type whose sitemap file group is also `pages` (the fixture's `acme-page`) shares the file and the HTML-sitemap group with the registry's `page` kind. The README states it.
- **Route report size.** The framework's default body limit (`http.body_limits.default_bytes`) can refuse a report larger than 1 MB before the 2000-path cap applies. The layer's reports of static routes are far smaller.
- **Registry history.** Undo of a registry page's history row answers `history.undo_unsupported`; registry undo was not planned.
- `go test ./... -run X` prints "no tests to run" for subpackages while the named root tests pass (as in 33-02 to 33-04).
## Known Stubs
None.
## Threat Flags
None outside the plan's threat model:
- **T-33-18** (route report): constant-time token compare, a 2000-path cap, paths of at most 2048 bytes, insert-only, owned paths skipped; covered by `TestRouteReport`.
- **T-33-19** (`/site`): only public facts. The test asserts that no token and no registry type appear.
- **T-33-20** (llms.txt): built from the listed addresses only, off by default, 404 outside production.
- **T-33-21** (Pages admin and MCP): `golem15.seo.manage` on every route and screen. Covered by the parity test and the 401/403 cases.
- **T-33-22** (domain lists): host names only, no scheme or path, validated line by line.
- **T-33-23** (purge): one purge per transaction after commit, coalesced by the framework purger.
## Verification run
- **sm-seo-plugin, after every commit:**
- `go vet ./...` and `go test -short ./...`: green;
- Docker `go test ./... -count=1`: green, 67 top-level tests.
- **Named tests:** all pass.
- TestSitemapFilterTracer, TestFilterReasons, TestNoindexPublishedConflict, TestSitemapBlockedIssue.
- TestRegistry, TestRouteReport, TestSiteEndpoint, TestLLMSTxt, TestRobotsSitemapLine, TestSettingsLinks, TestPurgeAfterWrite.
- TestEveryAdminOperationHasTool, TestMCPReadToolsAreGET.
- TestResolverPrecedence and the Phase 32 suites.
- **sm-sitemap-plugin:** vet, short and the Docker suite green (sitemap without SEO).
- **From summercms.go:**
- `bash scripts/check-phase33.sh --plugins --marker`: PASS (vet and short tests of sitemap, seo and journal; vitest 77 and 244; marker clean).
- `bash scripts/check-phase33.sh --docker`: PASS (29 sitemap, 67 seo, 124 journal top-level tests).
- `--layers` was not run: it is known to be broken and is logged in deferred-items.md. No layer was changed.
- `--go` and `--consumers` were not run: no framework code and no consumer pointer changed in this plan.
- **Acceptance greps:**
- one line or more each for `golem15.sitemap` (plugin.go), `sm-sitemap-plugin/contract` (sitemap_filter.go and plugin.go), `sm-sitemap-plugin =>` (go.work), `"seo-page"` (registry.go), `/registry/routes` (routes.go), `subtle.ConstantTimeCompare` (api/registry.go), and `sitemap_blocked` (issues.go);
- four lines for the four page tools in mcp.go;
- `"/llms.txt"` and `"/site"` in routes.go;
- `links_new_tab` in settings_write.go, fields.yaml and resolver.go (via `OutboundOf`/`LinksNewTab`).
## User Setup Required
None for this plan. For sites (33-13):
- **Route report:** set `golem15.seo.route_report_token`, or rely on `notfound_token`, and give the layer the same token.
- **Purge:** configure `site_cache.purge_url` and `site_cache.purge_token`.
- **Sitemap site address:** set the sitemap's site address (setting or `golem15.sitemap.site_url`) to the public origin, because robots.txt now names its index.
## Next Phase Readiness
- **33-06:** adds `pagination` and `home_crumb` to `classes.SiteAnswer`, and further resolve keys next to `outbound`.
- **33-08's layer** can drop its fixtures for `/registry/routes` and `/site`: the plugin now answers in the shapes the layer reads.
- **33-14:** bump the `sm-sitemap-plugin` require to its new tag and tag the next minor of both plugins.
## Self-Check: PASSED
- FOUND: every file in key-files.created (checked with `[ -f ]`).
- FOUND commits in sm-seo-plugin: 17b810d, 9e802ec, 26193ae, 49d3879, 742a5b9, 59f0457, 2dfc606, 397c126, df93c86. `git rev-list --count 55f5e5d..HEAD` = 9.
- FOUND commit in sm-sitemap-plugin: 9e5c393.
- No uncommitted changes in sm-seo-plugin or sm-sitemap-plugin.
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,473 @@
---
phase: 33-seo-output-audit-and-content
plan: 06
subsystem: seo
tags: [sm-seo-plugin, json-ld, schema-org, breadcrumbs, hreflang, pagination, publisher, local-business, custom-elements, mcp, package-legitimacy]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-01 descriptor facts (Kind, Parents, Authors, PublishedAt, Images, Videos); 33-05 resolve answer with outbound, the /site answer, the page registry kinds"
provides:
- "One JSON-LD @graph per ok resolve answer (classes/schema Build), nodes linked by the P33-22 @ids"
- "Resolve keys html_lang, alternates, jsonld, breadcrumbs, pagination, seo.og.locale, seo.og.locale_alternates (additive)"
- "Publisher, Local business, Pagination tabs and the home crumb setting; P8 publisher preview"
- "Pagination forms (query or path) with one-hop 301s, invalid pages, suffixes with %pages% left for the layer"
- "GET /site pagination {form, param, segments} and home_crumb {name, url, names}"
- "SEO tab: Primary category (seoparent), Structured data (seoschema, E8), preview (E9, widget action seo_graph); P1 primary_gone, P2 schema type and trail rows"
- "GET /_seo/api/v1/graph and MCP seo_get_graph; primary_parent and schema on seo_set_url_seo and apply_batch"
- "Task 4 checkpoint answers: chromedp v0.21.0 and gen2brain/webp v0.6.4 approved"
affects: [33-07, 33-08, 33-09, 33-10, 33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 110000
tasks: 4
commits: 6
plan_head_before: df93c860011ee33158657a27f1a82ea1eeef6595
plan_head_after: d38049a
tech-stack:
added: []
patterns:
- "classes/schema is pure (no plugin imports): the resolver, the admin preview, the publisher preview and the MCP tool share Build and Validate"
- "Per-record structured data values live in the entry's data (primary_parent, schema) on every locale row, like the social image"
- "Pagination is folded into Resolve before the strict check, so normalisation, strict URLs and the page form reach the final address in one 301"
key-files:
created:
- ../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/structured.go
- ../sm-seo-plugin/updates/202610280004_add_golem15_seo_publisher_pagination.go
- ../sm-seo-plugin/controllers/settings/_publisher_preview.htm
- ../sm-seo-plugin/controllers/api/graph.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/tests/elements/schema.test.ts
- ../sm-seo-plugin/structured_data_test.go
modified:
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/site.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/sitemap_filter.go
- ../sm-seo-plugin/classes/entries.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/classes/owner.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/controllers/settings.go
- ../sm-seo-plugin/controllers/api/settings.go
- ../sm-seo-plugin/controllers/api/seo.go
- ../sm-seo-plugin/extensions.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/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/settings_test.go
- ../sm-seo-plugin/seo_tab_test.go
key-decisions:
- "Task 4 (Jakub 2026-11-04): github.com/chromedp/chromedp approved, pin v0.21.0 (v0.21.1 and v0.22.0 exist but are under two weeks old)"
- "Task 4 (Jakub 2026-11-04): github.com/gen2brain/webp approved, pin v0.6.4 (its fallback is libwebp transpiled to Go with wasm2go, not wazero)"
- "Every graph carries the full WebSite and publisher nodes, not only the home page, so each page's graph is self-contained (the tracer expects both on a post)"
- "home_crumb in the site answer is {name, url, names}: name and url are what the committed 33-08 layer reads; names adds the per-locale names the plan asked for"
- "The graph is skipped in the sitemap filter's production resolver (it decides on robots and canonical only)"
- "Primary category is offered for article kinds only; the value is <type>:<id> of one of the descriptor's Parents"
- "Page 2 and later always uses its own canonical (D-21), and its hreflang alternates point at the same page of each translation"
patterns-established:
- "A structured data refusal is a schema.Error code mapped to the schema.* copy, shared by the field type Decode, seo_set_url_seo and the graph tool"
requirements-completed: [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]
coverage:
- id: D1
description: "One @graph with WebPage, BlogPosting (headline, author, dates, image, wordCount, articleSection), BreadcrumbList, VideoObject, WebSite and Organization linked by @id; breadcrumbs equal the BreadcrumbList items; alternates en, pl, x-default; none for a single-locale record"
requirement: "D-43"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestGraphTracer"
status: pass
human_judgment: false
- id: D2
description: "Breadcrumbs through the primary parent (stored, else first; recursive; unpublished parents skipped), per-locale home crumb, home page trail"
requirement: "D-86"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestGraphTracer/TestBreadcrumbs"
status: pass
human_judgment: false
- id: D3
description: "html_lang, og:locale and alternates from one source; paged alternates"
requirement: "D-55"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestGraphTracer/TestAlternates"
status: pass
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestPagedAlternates"
status: pass
human_judgment: false
- id: D4
description: "Publisher and Local business settings with every C20 refusal (422) in the admin and the API; the local business node; a person publisher; P8 with the logo finding; home crumb name"
requirement: "D-45"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestPublisherSettings"
status: pass
human_judgment: false
- id: D5
description: "Pagination query and path forms, one-hop 301s with normalisation, invalid pages, page 2+ canonical, robots and suffixes, /site pagination and home_crumb, segment and suffix refusals"
requirement: "D-40"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestPagination"
status: pass
human_judgment: false
- id: D6
description: "Custom JSON-LD refusals (invalid JSON with line/column, not an object, 16 KB, depth 8, script tag, type, price, currency), merge with the schema.merged notice, appended nodes, re-serialised storage on every locale row"
requirement: "D-44"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestCustomJSONLD"
status: pass
human_judgment: false
- id: D7
description: "Collection, profile, registry offer (Service with offer), contact page, home SearchAction; no e-commerce node"
requirement: "D-43"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestGraphPerKind"
status: pass
human_judgment: false
- id: D8
description: "GET /graph with findings and rules, unsaved schema preview, 401/403, primary_parent via seo_set_url_seo and apply_batch (dry run), P1 primary_gone, P2 rows"
requirement: "D-46"
verification:
- kind: integration
ref: "../sm-seo-plugin/structured_data_test.go#TestGraphTool"
status: pass
- kind: unit
ref: "../sm-seo-plugin/operations_test.go#TestEveryAdminOperationHasTool"
status: pass
human_judgment: false
- id: D9
description: "E8, E9 and the parent element: type options, Service grid and price backstop, JSON status with line and column, 16 KB, nodes and findings, allGood, 800 ms re-check, locale switch, failure line"
requirement: "D-44"
verification:
- kind: unit
ref: "../sm-seo-plugin/tests/elements/schema.test.ts"
status: pass
human_judgment: false
- id: D10
description: "The new settings tabs, E8, E9 and the primary category select in the real admin SPA: layout, spacing, the local business switch hiding its fields"
verification: []
human_judgment: true
rationale: "Rendering in the embedded admin SPA; the tests cover schemas, saves and element DOM in happy-dom, not the visual layout"
duration: 105min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 06: Structured data, breadcrumbs, hreflang and pagination Summary
**sm-seo-plugin now answers every page with one JSON-LD @graph (page, article, profile, service, breadcrumbs, videos, WebSite, publisher), its breadcrumb trail through the primary category, hreflang with x-default, and paginated addresses in the site's form, with publisher and local business settings, the Structured data fields and graph preview in the SEO tab, and seo_get_graph through MCP.**
## Task 4 answers: Go module legitimacy (Jakub, 2026-11-04)
33-10 and 33-11 pin exactly these versions, each after its decision note:
| Module | Answer | Pin | Checked by the orchestrator |
|--------|--------|-----|-----------------------------|
| `github.com/chromedp/chromedp` | **approved** | **v0.21.0** | Owned by the chromedp organisation, MIT. Dependencies: `github.com/chromedp/cdproto` and `github.com/go-json-experiment/json`. v0.21.1 (2026-11-01) and v0.22.0 (2026-11-03) exist, but both are less than two weeks old, so v0.21.0 is pinned. |
| `github.com/gen2brain/webp` | **approved** | **v0.6.4** | MIT. The only dependency is `github.com/ebitengine/purego`. Correction to the plan: the fallback without a system libwebp is libwebp transpiled to Go with wasm2go, not a WASM build run through wazero. |
Nothing was installed in this plan; no go.mod changed.
## Read first: versions, breaking changes, consumers
- **Versions.** The framework base is v0.6.0 and the target v0.6.1; this plan changed no framework code (`--marker` passed). `sm-seo-plugin` stays at tag v0.1.1 and now has 15 unpushed commits on main: 9 from 33-05 and 6 from this plan. The require on summercms and the go.work/replace setup are as 33-05 left them. Nothing was pushed or tagged.
- **Resolve answer.** Only additive keys:
- top level: `html_lang`, `alternates`, `jsonld`, `breadcrumbs`, `pagination`;
- inside `seo.og`: `locale`, `locale_alternates`.
No existing key changed. The new top-level keys are left out of the JSON when they are empty, and the layer reads a missing key as empty.
- **What a consumer will notice when moving to the next sm-seo-plugin:**
- **Migration 202610280004** adds settings columns, all with defaults.
- **New settings tabs:** Pagination, Publisher and Local business, plus a home crumb field on the General tab.
- **New SEO tab fields:** Structured data and its preview on every content type, and Primary category on article types.
- **`?page=1` now redirects.** A request for `?page=1`, or for `/strona/N` while the site uses the query form, now answers 301 where it used to answer ok. Page 2 and later now get suffixes on the title and description. This is what D-41 asks for, but sites will see it.
- **Core plugins.** Untouched: user, blog, pages, payment and journal. The vue-* layers were not changed.
## Tracer resolve answer (TestGraphTracer, `/blog/first-post`)
For comparison with the layer fixtures of 33-07 to 33-09:
```json
{
"action": "ok",
"alternates": [
{"hreflang": "en", "url": "https://www.example.test/blog/first-post"},
{"hreflang": "pl", "url": "https://www.example.test/pl/blog/pierwszy-wpis"},
{"hreflang": "x-default", "url": "https://www.example.test/blog/first-post"}
],
"breadcrumbs": [
{"name": "Home", "path": "/", "url": "https://www.example.test/"},
{"name": "Recipes", "path": "/blog/category/recipes", "url": "https://www.example.test/blog/category/recipes"},
{"name": "First post", "path": "/blog/first-post", "url": "https://www.example.test/blog/first-post"}
],
"html_lang": "en",
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{"@id": "https://www.example.test/blog/first-post#webpage", "@type": "WebPage",
"breadcrumb": {"@id": "https://www.example.test/blog/first-post#breadcrumb"},
"dateModified": "2026-10-05T12:30:00Z", "datePublished": "2026-10-01T08:00:00Z",
"description": "About the first post", "inLanguage": "en",
"isPartOf": {"@id": "https://www.example.test/#website"}, "name": "First post",
"primaryImageOfPage": {"@type": "ImageObject", "url": "https://www.example.test/media/first.jpg"},
"url": "https://www.example.test/blog/first-post"},
{"@id": "https://www.example.test/blog/first-post#article", "@type": "BlogPosting",
"articleSection": "Recipes",
"author": [{"@id": "https://www.example.test/blog/author/jan#profile", "@type": "Person", "name": "Jan Kowalski", "url": "https://www.example.test/blog/author/jan"}],
"dateModified": "2026-10-05T12:30:00Z", "datePublished": "2026-10-01T08:00:00Z",
"description": "About the first post", "headline": "First post",
"image": ["https://www.example.test/media/first.jpg"], "inLanguage": "en",
"isPartOf": {"@id": "https://www.example.test/blog/first-post#webpage"},
"mainEntityOfPage": {"@id": "https://www.example.test/blog/first-post#webpage"},
"publisher": {"@id": "https://www.example.test/#organization"}, "wordCount": 420},
{"@id": "https://www.example.test/blog/first-post#breadcrumb", "@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "item": "https://www.example.test/", "name": "Home", "position": 1},
{"@type": "ListItem", "item": "https://www.example.test/blog/category/recipes", "name": "Recipes", "position": 2},
{"@type": "ListItem", "item": "https://www.example.test/blog/first-post", "name": "First post", "position": 3}]},
{"@id": "https://www.example.test/blog/first-post#video-1", "@type": "VideoObject",
"description": "About the first post", "embedUrl": "https://www.youtube.com/embed/dQw4w9WgXcQ",
"isPartOf": {"@id": "https://www.example.test/blog/first-post#webpage"}, "name": "First post",
"thumbnailUrl": ["https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"],
"uploadDate": "2026-10-01T08:00:00Z", "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
{"@id": "https://www.example.test/#website", "@type": "WebSite", "inLanguage": "en", "name": "Acme Blog",
"publisher": {"@id": "https://www.example.test/#organization"}, "url": "https://www.example.test/"},
{"@id": "https://www.example.test/#organization", "@type": "Organization", "name": "Acme", "url": "https://www.example.test/"}
]
},
"locale": "en",
"outbound": {"new_tab": true, "nofollow_domains": [], "sponsored_domains": []},
"seo": {
"canonical": "https://www.example.test/blog/first-post",
"description": "About the first post",
"og": {"description": "About the first post", "image": "https://www.example.test/media/first.jpg",
"locale": "en_US", "locale_alternates": ["pl_PL"], "title": "First post | Acme Blog",
"url": "https://www.example.test/blog/first-post"},
"robots": "index, follow", "title": "First post | Acme Blog",
"twitter": {"card": "summary_large_image", "site": ""}, "verification": [], "x_robots_tag": "index, follow"
},
"staging": false,
"status": 200,
"url": "https://www.example.test/blog/first-post"
}
```
A page-3 answer in the query form adds `"pagination": {"form": "query", "invalid": false, "page": 3, "param": "page", "segment": ""}`. Its title is `Recipes – page 3 of %pages% | Acme Blog` and its description ends with `Page 3 of %pages%.` A garbage page value answers `not_found` with `"pagination": {..., "invalid": true}`.
## Performance
- **Duration:** about 105 min (11:56Z to 13:41Z)
- **Tasks:** 4: a tracer, two TDD tasks and the checkpoint, which Jakub answered in parallel.
- **Commits:** 6 in sm-seo-plugin (main, not pushed). The planning docs are committed in summercms.go.
## Accomplishments
- **classes/schema.** A pure package with:
- `Build`: one node builder per type;
- `Validate`: Google's required and recommended properties per type, with `rules` false for types Google has no rich-result rules for;
- `Normalize`, `DecodeCustom` and `Merge`: the C17 server rules for the structured data value and custom JSON-LD.
- **Resolve.**
- The breadcrumb trail goes through the primary parent: the stored choice, else the first parent, recursively up to 6 levels.
- Alternates list each translated locale, include x-default, and come with `html_lang` and the og locales.
- Pagination: the page form is parsed and folded into the one-hop decision, with invalid pages, suffixes, a self canonical and paged alternates.
- **Settings.**
- New Publisher, Local business and Pagination tabs and the home crumb field.
- The four-place pattern (model, writer, API, form) is applied to every new column, with all C20 refusals.
- P8 shows the generated publisher node with its findings.
- **SEO tab.**
- E8 (`seoschema`), E9 (widget `seo_graph`) and Primary category (`seoparent`).
- The `primary_parent` and `schema` values are stored in the entry data on every locale row.
- Saves answer the `schema.merged` notice.
- P1 gains `primary_gone`; P2 gains rows for the schema type and the breadcrumb trail.
- **MCP.**
- `seo_get_graph` (`GET /_seo/api/v1/graph?url=&schema=`).
- `seo_set_url_seo` and `apply_batch` accept `primary_parent` and `schema`.
- `seo_update_settings` documents every new key.
- The parity row `widget_action:seo_graph` maps to `get_graph`.
## Task Commits
sm-seo-plugin (branch main, not pushed):
1. **Task 1 (tracer):** `ea2ce24` feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)
2. **Task 2:**
- `fde5024` feat: publisher, local business and home crumb settings (group A)
- `032d3da` feat: pagination forms and suffixes in resolve and site (group B)
3. **Task 3:**
- `17da132` feat: graph validation and custom JSON-LD (groups A and C, plus the Go side of B)
- `10ff628` feat: structured data, graph preview and primary category in the SEO tab (group B: elements and dist)
- `d38049a` docs: structured data, breadcrumbs, hreflang and pagination in README (group D)
4. **Task 4:** no commit. The checkpoint answers are recorded at the top of this file.
The tracer gate ran in interactive end-of-phase mode. `TestGraphTracer` was re-run and passed before the expansion tasks started.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Adapted to the actual repositories
**1. [Plan shape] Fake content plugin `acme.magazine` instead of extending the fixture's pages plugin.**
- **Why:** `addresses_test.go` already registers an `acme.blog` plugin.
- **What I did:** the tracer and the per-kind tests use an in-memory plugin `acme.magazine` with three types: `acme-story` (article), `acme-section` (collection) and `acme-writer` (profile). It uses every 33-01 fact. fixture_test.go is unchanged.
**2. [Plan shape] Commit groups.**
- **Validator moved earlier:** validate.go landed in Task 2 group A, because P8 needs the findings.
- **Groups 3A and 3C are one commit**, together with the Go side of 3B (the SEO tab fields, the action, P1 and P2). The operations parity test binds the new `seo_graph` widget action to its tool, and the Go tests drive the SEO tab.
- **Group 3B** is the element commit: elements, labels, styles, dist and vitest.
**3. [Plan shape] `home_crumb` shape.**
- **The conflict:** the plan says "home_crumb per locale". The committed 33-08 layer reads `home_crumb.name` and `.url` (`SeoHtmlSitemapPage.vue`, `types.ts`).
- **What I did:** the site answer is `{"name": <default locale>, "url": <site>/, "names": {<locale>: <name>}}`. This serves both.
**4. [Rule 3 - Blocking] Repeater rows cannot have a trigger.**
- **The limit:** cabana refuses `trigger` on `type: repeater`.
- **Effect:** `local_hours` stays visible when the Local business switch is off. Every other local field hides as planned.
- **Logged** in deferred-items.md.
**5. [Rule 2 - Correctness] Additions outside the plan's list.**
- `classes/structured.go`: input composition, graph answers, the E9 action and the merge notices.
- `classes/owner.go`: `seo_set_url_seo` checks the new keys.
- `classes/history.go`: data values in history.
- `classes/sitemap_filter.go`: signature only. The production resolver skips the graph.
- New copy keys:
- `settings.url_invalid` and `settings.email_invalid` (website and email checks, T-33-25);
- `api.record_only`;
- `schema.field_price`, `schema.field_currency`, `schema.price_invalid` and `schema.currency_invalid`;
- `graphFailed` in E9 (the host gives the element no server message).
- Hreflang of page 2 and later points at the same page of each translation.
**6. [Plan shape] Files not touched.** These files from the plan's list needed no change:
- `updates/registry.go` (migrations register themselves);
- `controllers/settings/config_form.yaml`;
- `extension/_seo_status.htm` and `_seo_effective.htm`, which render the new callout and rows unchanged.
**7. [Plan shape] `field_types.go` (root) has no new lines.** The new types are registered through `classes.FieldTypes`, which the root file returns. The acceptance grep counts 2 lines in `classes/field_types.go`.
**8. [Rule 1 - Bug] The seoparent value round trip.**
- **Bug:** the admin sends the loaded `{selected, options}` object back when the editor leaves the field untouched.
- **Fix:** Decode reads `selected` from it.
**9. [Plan shape] E8 default for registry offers.** The element cannot know a registry page's kind, so the default option of a page-kind record reads "Default (WebPage)" even when the server's default is Service (offer and service kinds). The server applies Service correctly.
### TDD note
The plan is `type: execute` with `tdd="true"` tasks. Each group's tests were written alongside its code and run before the commit. The failures fixed before committing were:
- the local type copy keys;
- a nil `Settings{}` in TestTemplates;
- the tab expectations;
- the site JSON field order;
- the `<` check.
No separate RED commit was made: the plan needs green tests at every commit, as in 33-04 and 33-05.
### Auth gates
None.
**Total deviations:** 9. Five are plan shape, one is a blocking framework limit, one is a correctness addition, one is a bug and one is a UI default note.
**Impact on plan:** every must-have truth is met. The only exception is the visual hiding of the opening-hours repeater.
## Issues Encountered
- **Unvalidated per-locale values.** For the translatable settings (`page_segment`, the suffixes, `breadcrumb_home`, `publisher_name`), the admin's other-locale values go through the translation writer without Decode, so only the default locale's value is validated on save. An invalid segment in another locale falls back to the default segment when it is read.
- **Undo does not restore the new data values.** Undo of an entry history row restores the Phase 32 columns, not `primary_parent` or `schema`. History does record both.
- **`go test ./... -run X`** prints "no tests to run" for subpackages while the named root tests pass, as in 33-02 to 33-05.
## Known Stubs
None.
## Threat Flags
None outside the plan's threat model:
- **T-33-24** (custom JSON-LD): parse, objects only, 16 KB and depth 8, `<script` refused, re-serialised with `<` escaped. Covered by `TestCustomJSONLD`.
- **T-33-25** (sameAs and the website): https absolute URLs only, line-numbered refusal. Covered by `TestPublisherSettings`.
- **T-33-26** (breadcrumbs and alternates): built only from descriptors and settings.
- **T-33-27** (graph tool): the manage wrapper applies, with 401 and 403 in `TestGraphTool`.
- **T-33-28** (the parent chain): capped at 6 levels, cycle-safe.
- **T-33-SC** (installs): no package was installed.
## Verification run
- **sm-seo-plugin, after every commit:**
- `go vet ./...` and `go test -short ./...`: green;
- Docker `go test ./... -count=1`: green.
- **Named tests:** all pass.
- TestGraphTracer (with TestAlternates and TestBreadcrumbs), TestPublisherSettings, TestPagination (including its TestSiteEndpoint subtest), TestPagedAlternates, TestCustomJSONLD, TestGraphPerKind, TestGraphTool.
- TestSiteEndpoint, TestNormalize, TestSEOTab, TestEveryAdminOperationHasTool, TestLangKeysComplete, TestSettingsTypeTabs, TestResolverPrecedence and the Phase 32 and 33-05 suites.
- **Elements:**
- `npm run test`: 87 passed (10 new);
- `npm run build`, then `scripts/check-dist.sh`: matches.
- **From summercms.go:** `bash scripts/check-phase33.sh --plugins --marker`: PASS (vitest 87 and 244; marker clean).
- **Not run here:**
- `--layers`: known broken (deferred-items.md).
- `--docker`: it would also run sm-journal-plugin, which 33-09 is editing at the same time. The SEO Docker suite was run directly instead.
- `--go` and `--consumers`: no framework or consumer change.
- **Acceptance greps:**
- `func Build(`: 1;
- the four resolve json tags: 4;
- the settings_write keys: 15;
- `%pages%`: 3;
- `"pagination"`/`"home_crumb"` in site.go: 2;
- `"seoschema"`/`"seoparent"`: 2;
- the three element tags in index.ts: 3;
- `"get_graph"`: 1;
- `Product` in classes/schema: 0.
## User Setup Required
None for this plan. Optional per site:
- `golem15.seo.search_url` turns on the SearchAction.
- `golem15.seo.og_locales` overrides the og:locale values.
- The Publisher tab fills the publisher node; without it, the site name is used.
## Next Phase Readiness
- **33-07, 33-08 and 33-09:** the backend now answers the keys their fixtures expect (see the tracer answer above). Each layer fixture's `home_crumb` should gain `names`, or the layer can keep reading `name`.
- **33-10 and 33-11:** pin chromedp v0.21.0 and gen2brain/webp v0.6.4, each after its decision note.
- **33-14:** tag the next sm-seo-plugin minor, which includes 33-05 and this plan.
## Self-Check: PASSED
- FOUND: every file in key-files.created (checked with `[ -f ]`).
- FOUND commits in sm-seo-plugin: ea2ce24, fde5024, 032d3da, 17da132, 10ff628, d38049a. `git rev-list --count df93c86..HEAD` = 6.
- No uncommitted changes in sm-seo-plugin.
---
*Phase: 33-seo-output-audit-and-content*
*Completed: 2026-11-04*

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,349 @@
---
phase: 33-seo-output-audit-and-content
plan: 07
subsystem: vue-seo-layer
tags: [nuxt-layer, nitro-middleware, json-ld, hreflang, lru-cache, root-files, site-cache-purge, labels, css-tokens]
requires:
- phase: 32-seo-foundation
provides: GET /_seo/api/v1/resolve answer (action, status, location, url, locale, staging, seo), POST /notfound, verification files
- phase: 33-01
provides: version ruling (phase targets SummerCMS v0.6.1, base v0.6.0)
provides:
- vue-seo-layer runtime on top of v0.1.1 (repository ../vue-seo-layer, branch main, 7 commits, not pushed, not tagged)
- Nitro middleware server/middleware/seo.ts (resolveCached, redirect/410/X-Robots-Tag, pagination.invalid 404, event.context.seo)
- useCmsSeo (html lang, hreflang, og:locale, one JSON-LD @graph), useSchemaNode, mergeGraph, isPagePath
- resolve LRU (seo.resolveCacheTtl, 1000 entries, in-flight de-duplication) and the site answer cache (server/lib/site.ts)
- shared POST /api/__site__/cache/purge moved in (library and plugin byte-identical, route also clears the SEO caches) plus a purge hook for the transition
- root-file proxies server/middleware/seo-root.ts (robots.txt, sitemap.xml, sitemap-<name>.xml, llms.txt, listed verification and IndexNow key files)
- local module lib/seo-module.ts (configKey seo; cms, breadcrumbs, links.newTabHint, rootFiles, resolveFiles)
- useSeoLabels / pickLabels / formatSeoLabel (L8 en, pl), seo-tokens.css, seo-base.css
- smoke harness (count=, exact header=, noheader=, reqheader=, repeatable options, resolves/apicalls/noreport lines, --no-site), fixture API (site, root files, resolve counts, reload), purge-check.mjs, purge-token-check.mjs
affects: [33-08, 33-09, 33-12, 33-13, 33-14]
actuals:
tokens: 42900
tasks: 4
commits: 7
plan_head_before: 7d52c5d2e50fb5870887751adfdcd5e78512a271
plan_head_after: 6636cde70a6d866e6d7511c24f5d3b70258455f9
tech-stack:
added: []
patterns:
- "Resolve once per request in Nitro middleware, hand the answer to the render through event.context.seoResolved; the route middleware reuses it on the server and resolves only on client navigation"
- "New behaviour gated on the backend's v0.2.0 site answer: a backend whose /site answers 404, or one never reached, keeps the v0.1.1 behaviour"
- "LRU with generation counter: a clear detaches loads in flight, so nothing fetched before a purge is stored after it"
- "Smoke runs per scenario (v0.1.1 suite against --no-site, full, off via both switches, backend down, no token)"
key-files:
created:
- ../vue-seo-layer/lib/seo-module.ts
- ../vue-seo-layer/server/middleware/seo.ts
- ../vue-seo-layer/server/middleware/seo-root.ts
- ../vue-seo-layer/server/lib/config.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-cache-purge.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/app.config.ts
- ../vue-seo-layer/app/assets/css/seo-tokens.css
- ../vue-seo-layer/app/assets/css/seo-base.css
- ../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/expect-seo-off.txt
- ../vue-seo-layer/testing/expect-seo-down.txt
- ../vue-seo-layer/testing/expect-seo-notoken.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
- ../vue-seo-layer/tests/rootFiles.test.ts
modified:
- ../vue-seo-layer/nuxt.config.ts
- ../vue-seo-layer/package.json
- ../vue-seo-layer/README.md
- ../vue-seo-layer/app/plugins/seo-head.ts
- ../vue-seo-layer/app/middleware/seo.global.ts
- ../vue-seo-layer/app/composables/useSeoResolve.ts
- ../vue-seo-layer/app/utils/seo.ts
- ../vue-seo-layer/app/utils/types.ts
- ../vue-seo-layer/server/middleware/seo-verification.ts
- ../vue-seo-layer/server/plugins/seo-notfound.ts
- ../vue-seo-layer/testing/smoke-ssr.mjs
- ../vue-seo-layer/testing/fixture-api.mjs
- ../vue-seo-layer/tests/seo.test.ts
- ../vue-seo-layer/.playground/nuxt.config.ts
key-decisions:
- "Task 1 (Jakub 2026-11-04): the shared layer's name is vue-seo-layer"
- "Option 1 (Jakub 2026-11-04): extend the existing v0.1.1 repository in place (no git init, no re-scaffold, remote origin kept, never pushed or tagged); backward compatibility with v0.1.1 mandatory"
- "33-14 does not create the repository with tea; the layer's next tag is v0.2.0"
- "Module option seo.cms is an alias of public.seo.enabled (NUXT_PUBLIC_SEO_ENABLED); either one false switches the layer off"
- "Redirect Location keeps the v0.1.1 rule: same-site targets become paths, other hosts stay absolute; built from the answer only"
- "Client navigation keeps public.seo.apiBase (/_seo/api/v1); GET /api/__seo__/resolve is opt-in by setting it to /api/__seo__"
- "Root files and the root_files-only verification rule apply only once the backend gave a v0.2.0 site answer; otherwise v0.1.1 behaviour"
patterns-established:
- "Compatibility suite: testing/expect.txt (v0.1.1) runs unchanged against a backend without /site on every smoke run"
requirements-completed: [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]
coverage:
- id: D1
description: "A Nuxt page extending the layer renders the CMS title, canonical, hreflang, html lang, og:locale and exactly one JSON-LD script with one @graph (CMS nodes plus layer nodes merged by @id; '<' escaped)"
requirement: "D-43"
verification:
- kind: integration
ref: "npm run smoke:ssr (testing/expect-seo.txt tracer lines)"
status: pass
- kind: unit
ref: "tests/graph.test.ts, tests/seo.test.ts#seoHeadInput: v0.2.0 keys"
status: pass
human_judgment: false
- id: D2
description: "Redirect (301/302, Location from the answer only, crafted Host ignored), 410, X-Robots-Tag and the pagination.invalid 404 enforced in Nitro middleware before the render and the route cache"
requirement: "D-20a"
verification:
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt redirect/410/404 lines); testing/purge-check.mjs step 8 (redirect on a route-cached page)"
status: pass
human_judgment: false
- id: D3
description: "Resolve LRU (TTL, 1000 entries, in-flight de-duplication, purge-safe) and the moved site cache purge route clearing route cache, resolve and site caches"
requirement: "D-97"
verification:
- kind: unit
ref: "tests/lru.test.ts, tests/sitePurge.test.ts"
status: pass
- kind: integration
ref: "npm run purge:check (purge-check 18 checks, purge-token-check 53 checks); smoke 'resolves /blog/lwia-grzywa 1'"
status: pass
human_judgment: false
- id: D4
description: "Root files from the backend (robots.txt, sitemap.xml, sitemap-<name>.xml, llms.txt, listed verification and IndexNow key files) with status, content type, X-Robots-Tag; 503 Retry-After 60 on backend failure; other paths pass through"
requirement: "D-05"
verification:
- kind: unit
ref: "tests/rootFiles.test.ts"
status: pass
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt root lines, expect-seo-down.txt)"
status: pass
human_judgment: false
- id: D5
description: "404 report once with the bearer token; none with the layer off or without a token"
requirement: "D-20"
verification:
- kind: integration
ref: "npm run smoke:ssr (report/noreport lines in expect-seo.txt, expect-seo-off.txt, expect-seo-notoken.txt)"
status: pass
human_judgment: false
- id: D6
description: "L8 labels (en, pl, app overrides), seo-tokens.css and seo-base.css (only .seo-* classes), no i18n dependency"
verification:
- kind: unit
ref: "tests/labels.test.ts"
status: pass
- kind: integration
ref: "smoke line '<p class=\"seo-visually-hidden\">Ścieżka nawigacji</p>'; plan acceptance greps"
status: pass
human_judgment: false
- id: D7
description: "v0.1.1 behaviour and config keys preserved for consumers"
verification:
- kind: integration
ref: "testing/expect.txt (31 v0.1.1 checks) on every smoke run; expect-seo-off.txt with NUXT_PUBLIC_SEO_ENABLED=false; scratch build of vue-grzybyfunkcjonalne-app against this layer: its smoke 52/52, smoke:ga 2/2, unit 28/28"
status: pass
human_judgment: false
duration: 25min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 07: vue-seo-layer runtime Summary
**The existing vue-seo-layer v0.1.1 now resolves each page once in Nitro middleware with a purge-safe LRU, enforces redirect, 410 and X-Robots-Tag before the route cache, renders hreflang and one merged JSON-LD @graph, serves the CMS's root files, owns the shared site-cache purge route and ships the L8 labels and tokens. Every v0.1.1 behaviour and config key still works.**
## Task 1 answer and Jakub's ruling (2026-11-04)
- **Task 1 (checkpoint:decision), answered:** the layer's name is **vue-seo-layer**.
- **The plan was stale.** It assumed `../vue-seo-layer` did not exist. In fact the repository was created and published on 2026-10-31, outside this phase: remote `git@git.golem15.com:golem15/vue-seo-layer.git`, tags v0.1.0 and v0.1.1, branch main with 2 commits (ac59f3c, 7d52c5d). It is consumed by `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` pinned at `#v0.1.1`.
- **Jakub's ruling (option 1): extend the existing layer in place.** So:
- no `git init`, no recreate or re-scaffold, no file deleted, work stayed on main;
- the acceptance criterion "no remote" is void (origin exists);
- nothing was pushed or tagged.
- **Backward compatibility with v0.1.1 is mandatory.** The plan's new keys were added alongside the existing ones. Where a plan key duplicates an existing one, the existing key keeps working and the new one is an alias: `seo.cms` is an alias of `public.seo.enabled`, and either one false switches the layer off.
- **Commit subjects.** The plan's subjects were used. No scaffold commit exists, so the tracer subject describes the actual change.
- **For later plans:**
- **33-14** does NOT create the repository with tea.
- **The layer's next tag is v0.2.0.** `package.json` still says 0.1.1; bump it when tagging in 33-14. The README already shows the `#v0.2.0` pin and marks new features "v0.2.0".
- **Framework context (33-01):** the phase targets SummerCMS v0.6.1 on base v0.6.0.
- **No planned change needed a checkpoint:decision.** Every planned change could be made without changing observable v0.1.1 behaviour. One regression I introduced myself was found by the consumer build and fixed (deviation 6).
### How v0.1.1 compatibility was verified
1. **The v0.1.1 suite is unchanged.** `testing/expect.txt` (31 checks: head priority, empty values keep page tags, staging noindex, redirects, 410, 404 reports, verification files, `404 /robots.txt`, relative canonicals) runs on every `npm run smoke:ssr` against a backend whose `/site` answers 404, as v0.1.1's backend does. It passes on the final commit.
2. **Both switches tested.** `testing/expect-seo-off.txt` runs once with the new `NUXT_PUBLIC_SEO_CMS=false` and once with the v0.1.1 `NUXT_PUBLIC_SEO_ENABLED=false`. Both give zero API calls, the pages' own head, no redirect, no root files and no report.
3. **Consumer build.** In a scratchpad copy of `vue-grzybyfunkcjonalne-app`, its node_modules copy of vue-seo-layer was replaced with `git archive HEAD` of this layer. The real repository was not touched. I ran `nuxt build`, then its `smoke:ssr` (52/52), `smoke:ga` (2/2) and `npm test` (28/28). This found deviation 6, which is fixed. The consumer build also runs next to `@nuxtjs/seo`, which shares the `seo` config key, without errors.
4. **Config keys kept with v0.1.1 semantics:** `seo.apiBase`, `seo.notfoundToken`, `seo.timeoutMs`, `public.seo.apiBase` (still the browser's resolve base) and `public.seo.enabled`. The exported helpers `useSeoAnswer`, `resolveSeo`, `seoHeadInput` and the rest of `app/utils/seo.ts` keep their signatures. The new head keys are only added when the answer carries them, so an old backend gives the same tags.
## Performance
- **Duration:** 25 min
- **Started:** 2026-11-04T08:07:26Z
- **Completed:** 2026-11-04T08:32:38Z
- **Tasks:** 4 (Task 1 answered, Tasks 2 to 4 executed)
- **Files modified:** 57 in ../vue-seo-layer
## Accomplishments
- **Nitro middleware** resolves each page request once through the LRU, forwarding X-Forwarded-For. It handles redirect, 410, X-Robots-Tag and the pagination.invalid 404 before the render and the route cache. The render reuses the answer, so a page costs one resolve call; the smoke checks this with `resolves PATH 1`.
- **One head owner** (seo-head plugin → `useCmsSeo`). It sets the v0.1.1 tags plus `<html lang>`, og:locale and og:locale:alternate, hreflang links, and exactly one JSON-LD script with one @graph. The graph merges CMS nodes and `useSchemaNode` nodes by @id, with CMS values winning.
- **Shared purge route** moved in with the same contract. It also empties the resolve and site caches. A purge hook does the same when the journal, menu or FAQ layer's copy of the route answers instead (Pitfall 6 during the transition to 33-09).
- **Root-file proxies** with status, content type and X-Robots-Tag passed on, and 503 with Retry-After 60 on backend failure. They are gated on the v0.2.0 site answer.
- **L8 labels, tokens and base styles**, a README covering everything, and 67 unit tests.
- **Six smoke runs** (93 checks) plus the purge checks (18 + 53).
## Task Commits
All in /media/nvme/dev/golem15/summercms.io/summercms/vue-seo-layer (branch main, not pushed):
1. **Task 1: name the layer.** No commit; answered: vue-seo-layer.
2. **Task 2: tracer.** `160b53b` feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
3. **Task 3:**
- A `e3baa70` feat: redirect and 410 enforcement, X-Robots-Tag and the resolve cache
- B `691fa09` feat: shared site cache purge route clears the SEO caches
- C `d162943` feat: root files from the CMS and the 404 report
4. **Task 4:**
- A `aa7480e` feat: SEO labels, tokens and base styles
- B `c2357cd` docs: README
5. **Deviation fix:** `6636cde` fix: root files fall through until the backend has given a site answer
## Decisions Made
See key-decisions in the frontmatter. In short:
- **seo.cms** is an alias of public.seo.enabled.
- **Redirect Location** keeps the v0.1.1 same-origin-to-path rule.
- **The browser** keeps public.seo.apiBase; the new `/api/__seo__/resolve` is opt-in.
- **Root files** depend on a confirmed v0.2.0 site answer.
## Deviations from Plan
### Ruling-driven (Jakub, option 1)
**1. The repository exists.**
- **What changed:** no `git init`, no scaffold, remote kept, "no remote" criterion void.
- **Fixture layout:** the v0.1.1 fixtures (`testing/fixtures/resolve.json`, `verification/`) and `testing/expect.txt` were kept as they are. The new answers live in `testing/fixtures/seo/`, and the fixture API merges both.
### Compatibility-driven (Rule 2, to keep v0.1.1 behaviour)
**2. Redirect Location.**
- **Plan:** "forward the resolver's absolute location" (`/stary` → `https://example.test/nowy`).
- **What I did:** kept v0.1.1's `seoRedirectTarget`. A same-site target becomes a path (`/nowy`); another host stays absolute.
- **Prohibition still holds:** the Location never uses request input. The smoke sends a crafted Host and X-Forwarded-Host, and `server/middleware/seo.ts` has no `getRequestHost` or `x-forwarded-host`.
**3. Client-navigation resolve.**
- **Plan:** fetch through `/api/__seo__/resolve`.
- **What I did:** the browser still uses `public.seo.apiBase`, whose v0.1.1 meaning is the same-origin resolve base. The `/api/__seo__/resolve` route exists, goes through the server LRU, has `cache: false` and noindex, and is used when the app sets `public.seo.apiBase` to `/api/__seo__`.
**4. Head application.**
- **Plan:** `seo-head.ts` takes the answer from `useRequestEvent().context.seo`.
- **What I did:**
- The v0.1.1 global route middleware stays. On the server it reuses `event.context.seoResolved` (no second call). It still resolves paths the Nitro middleware skips (dotted page paths) and client navigations.
- The canonical link has no explicit `key`. unhead's own canonical de-duplication then keeps one canonical even when a page sets its own, which v0.1.1's `/about` check relies on.
**5. Verification files.**
- **Plan:** only names listed in `root_files` are proxied; others pass through.
- **What I did:** that rule applies when the backend gives a site answer. With a backend whose `/site` answers 404 (v0.1.1 era) or one never reached, `seo-verification.ts` keeps the v0.1.1 name lookup. robots.txt and the sitemaps are not proxied then, matching v0.1.1's `404 /robots.txt`.
- **Backend base:** `seo.backendBase` defaults to empty, which means `apiBase` without `/_seo/api/v1`. That is effectively `http://127.0.0.1:8096`, and an app that set only `NUXT_SEO_API_BASE` needs nothing new.
**6. [Rule 1 - Bug] Root files answered 503 while the backend was unreachable since start.**
- **Found during:** the post-plan consumer build. vue-grzybyfunkcjonalne-app's smoke expects `404 /robots.txt` and `404 /sitemap.xml` with the SEO API down.
- **Fix:** root proxies need a site answer (state ok, or the last one seen while the backend is down), so a cold start with the backend down falls through as in v0.1.1. The plan's 503 still applies once a site answer was seen: `503 /sitemap-fail-1.xml` in expect-seo.txt, and expect-seo-down.txt now expects the v0.1.1 404s.
- **Files:** server/lib/site.ts, server/middleware/seo-root.ts, tests/rootFiles.test.ts, testing/expect-seo-down.txt, README.md
- **Commit:** 6636cde
### Other adjustments
**7. Purge route copy and its transition hook.**
- **Byte-identical:** the purge library and the site-config plugin match the journal, menu and FAQ copies.
- **Not byte-identical:** the route adds `clearSeoCaches()`; its pinned hash in `tests/sitePurge.test.ts` is updated.
- **Added `server/plugins/seo-cache-purge.ts`** (an afterResponse hook on a 200 POST to `/api/__site__/cache/purge` or `/api/__journal__/cache/purge`). In an app that also extends journal, menu or FAQ, Nitro may run their copy, which does not clear the SEO caches (Pitfall 6). 33-09 removes those copies; the hook can stay, since clearing twice is harmless.
**8. purge-check.mjs.**
- **Changed:** it changes the fixture resolve title (as planned). It drops the journal-only checks: browser `max-age=0` comes from vue-journal-layer's journal-revalidate plugin, which was not moved, and `_payload.json` carries no SEO data.
- **Added:** step 8, where a redirect added for a route-cached page applies at once with `NUXT_SEO_RESOLVE_CACHE_TTL=0`.
- **Unchanged:** all other checks of both scripts.
**9. Playground route cache.**
- **Plan:** `'/**': { swr: 300 }`. **Used:** `'/blog/**'`.
- **Why:** a route-cached render has no Host header, so v0.1.1's `/relative` check (a path canonical made absolute on the request origin) needs uncached pages. Pre-existing behaviour, see Deferred.
**10. Smoke harness grammar.**
- **header=:** now means "equals", per P33-29. All v0.1.1 lines still pass.
- **Added:** `noheader=`, `reqheader=`, `noreport`, `resolves PATH N`, `apicalls N` and `--no-site`.
- **Runs:** `smoke:ssr` runs six scenarios instead of one; `purge:check` script added.
- **Boot probe:** `testing/nuxt-server.mjs` probes `/_nuxt/` so the boot never resolves a page.
**11. TDD gate.**
- **Not done:** no separate RED commits.
- **Why:** the plan requires `npm run test` green at every commit.
- **What was done:** tests were written and run failing before each implementation (`lru.test.ts`, `labels.test.ts`: ERR_MODULE_NOT_FOUND, plus a deliberate run with `clearSeoCaches()` removed failing three sitePurge tests). Each was then committed with its implementation in the plan's commit groups.
## TDD Gate Compliance
- RED observed locally (not committed) for `tests/lru.test.ts`, `tests/labels.test.ts` and the sitePurge cache-clear tests.
- GREEN in commits e3baa70, 691fa09 and aa7480e.
- No refactor commits.
- A separate `test(...)` commit was not made because the plan requires a green test suite at every commit (deviation 11).
## Known Stubs
Intentional forward options, wired in later plans and not blocking this plan's goal:
- **`lib/seo-module.ts` `breadcrumbs`** (default true): reaches `runtimeConfig.public.seo.breadcrumbs`; `<SeoBreadcrumbs>` arrives in 33-08.
- **`lib/seo-module.ts` `links.newTabHint`** (default true): reaches `runtimeConfig.public.seo.links.newTabHint`; the body-link pipeline is 33-09.
- **`nuxt.config.ts` `seo.routeReportToken`:** read by `seoServerConfig`; the route report is 33-08.
- **`app/assets/css/seo-base.css`:** only `.seo-visually-hidden` and the focus ring so far. Breadcrumb, pagination and sitemap rules land with their components in 33-08.
## Deferred Issues
- **Pre-existing in v0.1.1, out of scope:** on a route-cached (swr) render the request has no Host, so a canonical, og:url or og:image that the backend answers as a path becomes absolute on `http://localhost`. It does not affect backends with `golem15.seo.site_url` or `app.url` set, which the README already requires. Logged in deferred-items.md.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: new-endpoint | ../vue-seo-layer/server/api/__seo__/resolve.get.ts | Public GET /api/__seo__/resolve?url= returns the same public resolve answer the backend's /_seo/api/v1/resolve gives. Only path URLs up to 2048 characters; goes through the LRU, never route-cached, noindex, 404 when the layer is off. |
| threat_flag: proxy | ../vue-seo-layer/server/middleware/seo-root.ts | Root-file proxy (T-33-32): targets are the server-only bases plus an allow-listed name (exact names, `sitemap-[a-z0-9-]+.xml`, names listed in root_files); covered by tests/rootFiles.test.ts including traversal inputs. |
## Self-Check: PASSED
- All key files exist in ../vue-seo-layer (checked after writing).
- Commits 160b53b, e3baa70, 691fa09, d162943, aa7480e, c2357cd and 6636cde are present on main in ../vue-seo-layer.
- `npm run test`: 67 pass, 0 fail.
- `npm run smoke:ssr`: six runs ok (31, 36, 9, 9, 6, 2 checks).
- `npm run purge:check`: purge-check ok (18), purge-token-check ok (53).
- Plan acceptance greps:
- `sendRedirect` appears once in seo.ts; no `getRequestHost` or `x-forwarded-host`.
- expect-seo.txt has more than 5 `header=` or `count=` lines.
- `application/ld+json` appears once in useCmsSeo.ts.
- package.json has no `"dependencies"` and no i18n.
- Both the `--seo-target-size` and `--seo-space-5` tokens are present.
- No bare-element rules in seo-base.css.

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,406 @@
---
phase: 33-seo-output-audit-and-content
plan: 08
subsystem: vue-seo-layer
tags: [nuxt-layer, build-guard, ssr, noindex, route-report, drift-check, breadcrumbs, pagination, html-sitemap]
requires:
- phase: 33-07
provides: vue-seo-layer runtime (resolve middleware, useCmsSeo, site answer cache, labels, tokens, smoke harness, fixture API)
- phase: 33-03
provides: sitemap index /sitemap.xml and per-type files /sitemap-<slug>-<n>.xml through /_sitemap/files/{file} (unchanged here)
provides:
- SSR build guard (lib/guard.ts) with options seo.noindexRoutes and seo.indexable, guard:check over four fixture apps
- automatic noindex for declared routes and for pages whose meta sets requiresAuth or seo.robots (app plugin seo-route-robots)
- route artefact #seo-routes, the route report (POST /registry/routes) and the startup drift check
- GET /api/__seo__/site and useSeoSite()
- <SeoBreadcrumbs>, <SeoPagination> + usePagination(), HTML sitemap pages (SeoHtmlSitemapPage, GET /api/__seo__/sitemap) and <SeoSitemapLink>
- options seo.routeReport, seo.pagination, seo.htmlSitemap
affects: [33-09, 33-12, 33-13, 33-14]
actuals:
tokens: 42200
tasks: 3
commits: 7
plan_head_before: 6636cde70a6d866e6d7511c24f5d3b70258455f9
plan_head_after: b051bda0809b9a8ca58f2c55c55d12d6477e8127
tech-stack:
added: []
patterns:
- "Build-time checks in the local module: nitro:config (rules merged) runs before pages:resolved, so the guard runs once the pages are resolved; a production build throws, nuxt dev and nuxt prepare warn"
- "Build-time route data for runtime plugins through addServerTemplate (#seo-routes), no network call at build time"
- "Pagination and breadcrumb links through NuxtLink's custom slot, so the router's aria-current does not mark every page of the same path"
- "Helpers in app/utils carry the seo prefix (auto-imported next to the journal layer's paginationWindow)"
key-files:
created:
- ../vue-seo-layer/lib/guard.ts
- ../vue-seo-layer/lib/routes.ts
- ../vue-seo-layer/lib/options.ts
- ../vue-seo-layer/testing/guard-check.mjs
- ../vue-seo-layer/testing/guard/{indexable-ssr-false,noindex-ok,client-page,global-ssr-false}/
- ../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/lib/drift.ts
- ../vue-seo-layer/server/lib/routeReport.ts
- ../vue-seo-layer/server/lib/htmlSitemap.ts
- ../vue-seo-layer/server/seo-routes.d.ts
- ../vue-seo-layer/server/api/__seo__/site.get.ts
- ../vue-seo-layer/server/api/__seo__/sitemap.get.ts
- ../vue-seo-layer/app/composables/useSeoSite.ts
- ../vue-seo-layer/app/composables/usePagination.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/Seo/SitemapList.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/utils/paginationWindow.ts
- ../vue-seo-layer/app/utils/pageHref.ts
- ../vue-seo-layer/app/utils/breadcrumbClaim.ts
- ../vue-seo-layer/app/utils/sitePath.ts
- ../vue-seo-layer/testing/expect-seo-drift.txt
- ../vue-seo-layer/testing/fixtures/seo/site-drift.json
- ../vue-seo-layer/testing/fixtures/seo/html.json
- ../vue-seo-layer/.playground/app/pages/{konto,szkic}.vue, prywatne/index.vue, blog/index.vue
- ../vue-seo-layer/tests/{guard,routes,paginationWindow,pageHref,breadcrumbClaim,htmlSitemap}.test.ts
modified:
- ../vue-seo-layer/lib/seo-module.ts
- ../vue-seo-layer/server/lib/config.ts
- ../vue-seo-layer/app/composables/useCmsSeo.ts
- ../vue-seo-layer/app/composables/useSeoLabels.ts
- ../vue-seo-layer/app/utils/seo.ts
- ../vue-seo-layer/app/utils/types.ts
- ../vue-seo-layer/app/assets/css/seo-base.css
- ../vue-seo-layer/package.json
- ../vue-seo-layer/README.md
- ../vue-seo-layer/testing/smoke-ssr.mjs
- ../vue-seo-layer/testing/fixture-api.mjs
- ../vue-seo-layer/testing/expect-seo.txt, expect-seo-off.txt, expect-seo-down.txt, expect-seo-notoken.txt
- ../vue-seo-layer/testing/fixtures/seo/site.json, resolve.json
- ../vue-seo-layer/.playground/nuxt.config.ts, app/app.vue, app/pages/[...slug].vue
key-decisions:
- "The build guard runs when the pages are resolved (pages:resolved), not inside nitro:config: Nuxt fires nitro:config first; the rules are read from the final Nitro config (nitro:init), and nitro:build:before is the fallback for an app without pages"
- "The guard throws only in a production build or generate; nuxt dev and nuxt prepare (run on install) warn, so an install never fails"
- "Declared noindex and page-meta robots win over the CMS robots (tagPriority -10 against the head entry's critical -8), except on staging"
- "The route report retries once after 60 s only on no answer or a non-404/401/403 status; a 404 (no page registry) and a refused token are logged, not retried"
- "A root catch-all page does not count as serving a CMS content-type pattern in the drift check; a prefixed catch-all does"
- "app/utils helpers are seo-prefixed (seoPaginationWindow, seoPageHref, seoClaimBreadcrumbs, seoSitePath): the journal layer auto-imports its own paginationWindow with another return shape"
- "HTML sitemap pages answer 404 unless the CMS's site answer has the HTML sitemap on with a path for the locale, 503 when the backend has not answered; an app page at the same path keeps it"
patterns-established:
- "Smoke grammar: routereport N [has=PATH] [lacks=PATH], log TEXT:N, --site-file NAME"
- "Split commits verified on the staged tree: git checkout-index into a scratch dir, then test, build and smoke there"
requirements-completed: [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]
coverage:
- id: D1
description: "nuxt build fails with the D-70 message for a Nuxt route rule, a Nitro route rule, a .client.vue page and a global ssr: false, and passes when the route is declared in seo.noindexRoutes; the declared route is served with X-Robots-Tag: noindex, nofollow and / without it; inline route rules are refused; seo.indexable false checks nothing"
requirement: "D-70"
verification:
- kind: integration
ref: "npm run guard:check (testing/guard-check.mjs, 16 checks)"
status: pass
- kind: unit
ref: "tests/guard.test.ts"
status: pass
human_judgment: false
- id: D2
description: "A page with requiresAuth meta, a page with seo.robots and a path under noindexRoutes get the robots meta tag and X-Robots-Tag without page code; an ordinary page keeps the CMS robots"
requirement: "D-23"
verification:
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt /konto, /szkic, /prywatne, /plain lines)"
status: pass
- kind: unit
ref: "tests/routes.test.ts#pageMetaRobots"
status: pass
human_judgment: false
- id: D3
description: "One route report at server start with the token and the static paths (not /konto, /szkic, /prywatne, the sitemap pages); none without a token or with routeReport false"
requirement: "D-33"
verification:
- kind: integration
ref: "npm run smoke:ssr (routereport lines in expect-seo.txt, expect-seo-notoken.txt, expect-seo-drift.txt)"
status: pass
- kind: unit
ref: "tests/routes.test.ts#staticRoutes, routeReportBody, routeReportOutcome"
status: pass
human_judgment: false
- id: D4
description: "Startup drift check: no warning when CMS and build agree, three warnings (URL pattern, pagination form, pl HTML sitemap path) against site-drift.json, and the server still serves pages"
requirement: "D-20c"
verification:
- kind: integration
ref: "npm run smoke:ssr (log lines in expect-seo.txt and expect-seo-drift.txt)"
status: pass
- kind: unit
ref: "tests/routes.test.ts#driftWarnings"
status: pass
human_judgment: false
- id: D5
description: "<SeoBreadcrumbs>: one nav per page with a layout and a page instance, server-rendered links, last item aria-current text; none on the home page or a page without a trail"
requirement: "D-86"
verification:
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt L1 lines, count=<nav class=\"seo-breadcrumbs\":1)"
status: pass
- kind: unit
ref: "tests/breadcrumbClaim.test.ts"
status: pass
human_judgment: false
- id: D6
description: "<SeoPagination> + usePagination(): window 1 … 3–7 … 14, page 1 unnumbered, other query parameters kept, rel prev/next anchors and head links, %pages% filled in the CMS title and description, nothing with one page"
requirement: "D-42"
verification:
- kind: unit
ref: "tests/paginationWindow.test.ts, tests/pageHref.test.ts"
status: pass
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt L2 lines)"
status: pass
human_judgment: false
- id: D7
description: "HTML sitemap: overview with H1 and an H2 per non-empty group, nested categories, sitemap.all link, sitemap.empty; group pages with built trail, page suffix and pagination; 404 unknown group / past the last / malformed page, 301 ?page=1, 503 on API failure and with the backend down, 404 with the layer off; non-http URLs never rendered as links"
requirement: "D-34"
verification:
- kind: integration
ref: "npm run smoke:ssr (expect-seo.txt L3 lines, expect-seo-off.txt, expect-seo-down.txt)"
status: pass
- kind: unit
ref: "tests/htmlSitemap.test.ts"
status: pass
human_judgment: false
- id: D8
description: "<SeoSitemapLink> renders the locale's sitemap link only while the CMS has the HTML sitemap on and the layer serves it"
requirement: "D-34"
verification:
- kind: integration
ref: "npm run smoke:ssr (L4 line in expect-seo.txt and expect-seo-drift.txt; none in expect-seo-off.txt and the --no-site v0.1.1 run)"
status: pass
human_judgment: false
- id: D9
description: "Default styles of L1–L3 (only .seo-* selectors, tokens only, long text wraps)"
verification:
- kind: other
ref: "app/assets/css/seo-base.css review; no bare-element selector"
status: pass
human_judgment: true
- id: D10
description: "v0.1.1 compatibility: the v0.1.1 smoke suite unchanged; the scratch build of the existing consumer against this layer passes its own checks"
verification:
- kind: integration
ref: "testing/expect.txt (31 checks, --no-site) on every smoke run; scratch copy of the consumer app with node_modules/vue-seo-layer = git archive aee6957: nuxt build, smoke:ssr 52/52, smoke:ga 2/2, npm test 28/28"
status: pass
human_judgment: false
duration: 27min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 08: vue-seo-layer build guard, noindex routes, route report and public components Summary
**vue-seo-layer now fails a production build whose indexable routes would render only in the browser, marks declared and signed-in routes noindex without page code, reports its static routes to the CMS page registry and logs CMS/build drift at start. It also ships server-rendered breadcrumbs, pagination in the CMS URL form and an HTML sitemap with its footer link. All of it is additive to v0.1.1.**
## Context rulings applied
- **Framework version:** the framework is at v0.6.0 and this phase delivers v0.6.1. This plan changes no framework code, so D-90 holds.
- **The layer:** vue-seo-layer was extended in place on main after 33-07 (6636cde).
- Nothing was pushed or tagged.
- `package.json` stays at 0.1.1; the next tag is v0.2.0, in 33-14.
- **Sitemap paths (33-03):** the plan's layer code proxies no XML sitemap path, so no alignment was needed.
- The HTML sitemap pages call the sitemap plugin's HTML API at `/_sitemap/api/v1/html` and `/_sitemap/api/v1/html/{group}`, the contract planned in 33-04.
- 33-04 is not built yet, so these calls run against fixtures. The same is true of the SEO plugin's `POST /registry/routes` (33-05): 33-03 and 33-05 code had not landed in the plugins when this plan ran.
## Performance
- **Duration:** about 27 min (2026-11-04T10:37:49Z to 11:04:32Z)
- **Tasks:** 3 (tracer, then 2 TDD tasks)
- **Commits:** 7 in ../vue-seo-layer
- **Files:** 68 changed in ../vue-seo-layer
## Task Commits
All in /media/nvme/dev/golem15/summercms.io/summercms/vue-seo-layer (branch main, not pushed):
1. **Task 1 (tracer):** `b4680af` feat: the build fails when an indexable route renders only in the browser (tracer)
2. **Task 2:**
- A `9cc2cda` feat: noindex for declared and signed-in routes
- B `3abc986` feat: route report and drift check at server start
3. **Task 3:**
- A `ebb0c80` feat: breadcrumbs component
- B `f2d13d6` feat: pagination component and composable
- C `aee6957` feat: HTML sitemap pages and footer link
- D `b051bda` docs: build guard, noindex routes and components
The tracer gate ran in interactive end-of-phase mode with automated checks only. Its `<verify>` was re-run (tests plus `guard:check`) and passed before expansion.
## Accomplishments
### Build guard (D-70)
- `lib/guard.ts` holds the pure checks: `isNoindex` and `patternCovers` (Nitro route-rule semantics), `pagePattern`, `flattenPages`, `findOffenders` and `guardMessage`.
- The module collects every merged route rule from the final Nitro config, plus the `.client.vue` pages. It refuses `experimental.inlineRouteRules`.
- A production build throws `[vue-seo-layer] indexable routes must render on the server (D-70): …`.
- Each declared noindex pattern gets an X-Robots-Tag route rule.
- `testing/guard-check.mjs` builds four fixtures (about 14 s in total) and checks the header on the noindex fixture.
### Noindex routes (D-23, D-21)
- The module adds `requiresAuth` and `seo` to Nuxt's page-meta extraction keys.
- The app plugin `seo-route-robots` applies the robots meta tag (priority above the CMS head entry) and, during SSR, the header once navigation is done.
- Staging keeps the CMS's noindex.
### Route report and drift check (D-33, D-20c)
- `#seo-routes` is written from the resolved pages at bundle time.
- `seo-route-report` posts the static paths once per process.
- `seo-drift` compares URL patterns, pagination and HTML-sitemap paths with the site answer and logs one warning per mismatch.
- `GET /api/__seo__/site` and `useSeoSite()` share the cached site answer.
### Components (D-86, D-40 to D-42, D-34, D-71)
- `<SeoBreadcrumbs>`: one trail per page, claimed by the first instance per path.
- `<SeoPagination>` with `usePagination()`: hrefs in the CMS form, head prev/next links, and `%pages%` filled in the CMS title and description (`useCmsSeo` with `seoFillPages`).
- HTML sitemap pages, registered per locale through `extendPages` and rendering the overridable global `SeoHtmlSitemapPage`. They load data from `GET /api/__seo__/sitemap`.
- `<SeoSitemapLink>`.
- Default styles for L1 to L3.
### README
The README documents everything above.
## Compatibility notes for v0.1.1 consumers
All new behaviour is additive or tied to a declaration or a v0.2.0 backend answer:
- **v0.1.1 options:** every v0.1.1 option, runtime key and exported helper keeps its meaning. The v0.1.1 smoke suite (`testing/expect.txt`, 31 checks against a backend without `/site`) passes unchanged on every run.
- **Build guard:** it only fails an app with an indexable `ssr: false` route, a `.client.vue` page or a global `ssr: false`.
- The existing consumer has none of these and builds.
- The demo app's `/p/**` and `/presentations/**` must be declared in `seo.noindexRoutes` when it moves to v0.2.0, as 33-12 plans. Otherwise its build fails by design.
- `nuxt prepare` and `nuxt dev` only warn, so an install never fails.
- **Noindex:** applied only to routes the app declares, and to pages whose meta sets `requiresAuth: true` or `seo.robots`. An app that already uses `requiresAuth` for its own guard gets `noindex, nofollow` on those pages, which is the intended D-23 behaviour.
- **Route report:**
- It needs a token. It falls back to `NUXT_SEO_NOTFOUND_TOKEN`, so an app that set that token will start posting the report.
- A v0.1.1-era backend answers 404, which is logged once and not retried.
- `seo.routeReport: false` switches it off.
- **HTML sitemap pages:** registered by default at `/mapa-strony` and `/sitemap`.
- **If the app already has a page at one of these paths:** the app's page is kept.
- **CMS has no HTML sitemap** (no site answer, or the sitemap off or without a path for the locale): the pages answer 404. In the existing consumer these paths answered 404 before as well, through the journal's `/:category` route.
- **Backend unreachable since start:** they answer 503 instead of 404.
- The layer also statically claims these two paths ahead of a dynamic route such as `/:category`.
- `seo.htmlSitemap: false` registers none.
- **New global names:** components `SeoBreadcrumbs`, `SeoPagination`, `SeoSitemapLink`, `SeoSitemapList` and `SeoHtmlSitemapPage`; composables `usePagination`, `useSeoPages` and `useSeoSite`; utils prefixed `seo*`. None of them collides with the journal, menu or FAQ layers.
- **Consumer check:** the existing consumer app was copied to a scratch directory with `node_modules/vue-seo-layer` replaced by `git archive` of aee6957. The real repository was not touched. Results:
- `nuxt build` passed, guard included.
- `smoke:ssr` 52/52, `smoke:ga` 2/2, `npm test` 28/28.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Adapted to the actual repository and Nuxt
**1. Guard timing.**
- **Plan:** throw in `nitro:config`.
- **What happens in Nuxt 4.4.8:** `nitro:config` fires before `pages:resolved`, so the pages are not known there.
- **What I did:**
- `nitro:config` only adds the noindex header rules.
- The rules are read from the final Nitro config (`nitro:init`).
- The check runs in `pages:resolved`, with `nitro:build:before` as the fallback.
- The acceptance grep still prints the two hook lines.
**2. Dev and prepare only warn.** Throwing in `nuxt prepare`, which pnpm runs on install, would break installs. Production `build` and `generate` fail as planned.
**3. Helper names are prefixed.**
- **Plan names:** `paginationWindow`, `pageHref`, `breadcrumbClaim`.
- **Why changed:** vue-journal-layer already auto-imports a `paginationWindow` with another return shape. A second auto-import of that name in the same app would shadow it.
- **What I did:** the files keep the plan's names, and the exports are `seoPaginationWindow`, `seoPageHref` / `seoPageNumber` and `seoClaimBreadcrumbs` / `seoReleaseBreadcrumbs`.
**4. Extra files.** Each was split out of a planned file or task:
- `lib/options.ts` and `server/lib/{drift,routeReport,htmlSitemap}.ts`: pure parts, so they can be unit-tested.
- `app/utils/sitePath.ts`: same-origin URLs become paths; anything that is not http(s) is never a link.
- `app/components/Seo/SitemapList.vue`: the recursive category tree.
- `server/seo-routes.d.ts`: types for `#seo-routes`.
- `tests/routes.test.ts` and `tests/htmlSitemap.test.ts`.
**5. Fixture site answer.** `testing/fixtures/seo/site.json` changed its post pattern from `/blog/:slug` to `/posts/:slug`. The playground serves posts at `posts/[slug].vue`, while `/blog/<slug>` is rendered by the root catch-all, which by design does not count for the drift check. Only the drift check reads `routes`.
**6. Playground HTML sitemap paths.** The playground sets `htmlSitemap.paths` to `{pl: '/mapa-strony', en: '/en/sitemap'}`, matching the 33-07 fixture site answer. The layer default stays pl `/mapa-strony`, en `/sitemap`.
**7. Fixture for the "/blog with one page" case.** The blog listing takes `?pages=N`. "One page" is `/blog?pages=1`, because the fixture's `/blog` has 14 pages.
**8. HTML sitemap behaviour beyond the plan:**
- **Breadcrumbs:** the overview's trail comes from the resolver, as on any page.
- **`?page=1`:** the 301 is enforced in the layer page, since the resolver does not know these pages.
- **Path pagination form:** an extra route `<path>/:group/<segment>/:page` is registered.
- **Gating:** the pages answer only while the site answer has the HTML sitemap on with a path for the locale.
**9. [Rule 1 - Bug] `setResponseHeader` is not auto-imported in app code.**
- **Found during:** the Task 2 smoke (`/konto` answered 500).
- **Fix:** the header is set with `useResponseHeader` inside `nuxtApp.runWithContext`.
- **Files:** app/plugins/seo-route-robots.ts. Fixed before commit 9cc2cda.
**10. [Rule 1 - Bug] The router marked every query variant as the active page.**
- **Found during:** a Task 3 probe. NuxtLink's active match ignores the query, so every `/blog?page=N` link carried `aria-current="page"`.
- **Fix:** pagination and breadcrumb links render through NuxtLink's `custom` slot. They still navigate on the client, and only the current item carries `aria-current`. Fixed before commit f2d13d6.
**11. [Rule 2 - Security] Non-http(s) sitemap URLs.** A `javascript:` URL from the sitemap API renders as text, never as a link. The smoke covers it with a fixture entry.
**12. Smoke expectations adjusted to real markup:**
- **aria-current count:** `aria-current="page"` appears twice on `/blog?page=5` (the current page and the breadcrumbs' last item).
- **Payload:** the raw `%pages%` stays in the payload, so the check targets the title and description tags.
**13. Commit splits:**
- Task 2 A/B and Task 3 A/B/C share files.
- Intermediate versions of those files were staged with `git update-index`.
- Each staged tree was checked out into a scratch directory and passed `npm run test`, `build:playground` and `smoke:ssr` there before its commit.
### TDD gate
- Tests were written alongside each unit before running (guard, routes/drift/report, window/href/claim, htmlSitemap).
- **RED observed:**
- `pagePatterns` (a `?` stripped as a query, fixed in lib/guard.ts before the Task 1 commit).
- The smoke's `/konto` 500 (deviation 9).
- No separate RED commits, because the plan requires `npm run test` green at every commit (same as 33-07).
## Deferred Issues
- **The `scripts/check-phase33.sh --layers` stage cannot pass.**
- **Cause:** its `npm_vitest` step demands a vitest summary, while all four layers run `node --test` TAP ("refuse: no all-passing vitest summary", `test=0 detect=3`).
- **What I ran instead:** every step of the stage by hand: unit tests, playground builds and smokes for all four layers, and `guard-check.mjs`. All passed.
- **Logged in:** deferred-items.md, for a gate fix outside this plan.
- **No typecheck script.** The layer has no typecheck script, and its devDependencies lack `vue-tsc`. Types were not machine-checked, as in 33-07. The build (vite + oxc) compiled every file.
## Known Stubs
None. The sitemap HTML API (33-04) and the page registry (33-05) are not built yet. The layer code follows their planned contracts and is covered by fixtures, so it does not depend on stubs.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: new-endpoint | ../vue-seo-layer/server/api/__seo__/site.get.ts | Public GET /api/__seo__/site returns the backend's public site answer (cached); no-store, noindex, never route-cached |
| threat_flag: proxy | ../vue-seo-layer/server/api/__seo__/sitemap.get.ts | Public GET /api/__seo__/sitemap proxies the sitemap HTML API on the server-only backendBase; locale, group slug and page are checked by regex before use, only these reach the backend, the answer is reduced to known fields (T-33-38) |
| threat_flag: outbound-token | ../vue-seo-layer/server/plugins/seo-route-report.ts | The route-report token (server-only runtime config) is sent only to the configured apiBase (T-33-36) |
## Self-Check: PASSED
- **Files:** all key files listed above exist in ../vue-seo-layer.
- **Commits:** b4680af, 9cc2cda, 3abc986, ebb0c80, f2d13d6, aee6957 and b051bda are on main in ../vue-seo-layer (7 since 6636cde).
- **Unit tests:** `npm run test` 106 pass, 0 fail.
- **Smoke:** `npm run smoke:ssr` passed all seven runs (31, 64, 11, 11, 7, 3 and 7 checks).
- **Purge:** `npm run purge:check` 18 + 53 checks.
- **Guard:** `npm run guard:check` 16 checks.
- **Other layers:** the remaining steps of the gate's layers stage passed: journal, menu and FAQ unit tests, playground builds and smokes.
- **Acceptance greps:**
- The D-70 message is in lib/guard.ts.
- `nitro:config` and `pages:resolved` appear once each in seo-module.ts.
- `requiresAuth` is in seo-route-robots.ts and `drift:` in seo-drift.ts (the message prefix itself lives in server/lib/drift.ts).
- `registry/routes` in seo-route-report.ts prints two lines, not the planned one: the header comment and the call.
- `aria-current` is in Breadcrumbs.vue and Pagination.vue.
- No `ClientOnly` or `onMounted` in the components and pages.
- expect-seo.txt has `count=<nav class="seo-breadcrumbs":1`.

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,502 @@
---
phase: 33-seo-output-audit-and-content
plan: 09
subsystem: vue-journal-layer, vue-menu-layer, vue-faq-layer, vue-seo-layer, sm-journal-plugin
tags: [nuxt-layer, seo-layer, headings, h1, post-card, body-links, autolink, outbound, breadcrumbs, pagination, itemlist, faqpage, purge, related-posts]
requires:
- phase: 33-08
provides: vue-seo-layer components (SeoBreadcrumbs, SeoPagination, usePagination), useSchemaNode, labels, seo.links.newTabHint, seo.pagination, the shared purge route
- phase: 33-07
provides: vue-seo-layer runtime (resolve middleware, useSeoAnswer, useCmsSeo, one JSON-LD graph, smoke grammar)
- phase: 33-02
provides: content_h1_to_h2 on GET /posts/{slug} and every GET /categories row; the Journal policy keeps rel nofollow/sponsored/ugc and alt=""
provides:
- vue-seo-layer app/utils/bodyLinks.ts (autolink, applyOutbound, imageAttrs)
- vue-journal-layer on vue-seo-layer without @nuxtjs/seo; one H1 in both Journal H1 modes; journal.postTitleTag and journal.breadcrumbs
- renderJournalBody (L7 order), gateJournalHtml/sanitizeJournalHtml demoteH1, rel token policy
- one link per post card (headingLevel 2 | 3), SeoBreadcrumbs on journal pages, JournalPagination as SeoPagination, path-form listing routes, pillar text on page 1, ItemList nodes
- vue-menu-layer and vue-faq-layer on vue-seo-layer without purge copies; FaqBlock headingLevel 0 and schema (FAQPage node)
- sm-journal-plugin golem15.journal.related_mode / related_count (opt-in scored related posts, previous_in_category / next_in_category)
affects: [33-10, 33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 40100
tasks: 4
commits: 13
plan_head_before:
vue-seo-layer: b051bda0809b9a8ca58f2c55c55d12d6477e8127
vue-journal-layer: 1dd31760601e4bbbe93033b5aba6c5edb2c1bef3
vue-menu-layer: ef3a090647ac994fa920baa48464edcd8338a77f
vue-faq-layer: 1f23340a2f8bc14ac3bcd8819485dc13eca1fda3
sm-journal-plugin: 0edf6e1b67d4f7d8f62980e30d52292cce94faf3
plan_head_after:
vue-seo-layer: 7af91fc
vue-journal-layer: adcea4d
vue-menu-layer: 8b5da67
vue-faq-layer: f7ec4fe
sm-journal-plugin: ee3cb26
tech-stack:
added: []
patterns:
- "A content layer extends vue-seo-layer through a link dependency during development (P33-33); 33-14 swaps it for the git tag pin"
- "Body link steps live in vue-seo-layer (pure, auto-imported); the journal gate takes them as injected functions, so its node tests never import across packages"
- "Smoke checks of page structure: headings=strict (one h1, no skipped level) and cardlinks=N, outside <script> elements"
key-files:
created:
- ../vue-seo-layer/app/utils/bodyLinks.ts
- ../vue-seo-layer/tests/bodyLinks.test.ts
- ../vue-journal-layer/app/composables/useJournalItemList.ts
- ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json
- ../vue-journal-layer/testing/fixtures/acme/seo/site.json
- ../vue-journal-layer/testing/expect-acme-title-h1.txt
- ../vue-journal-layer/testing/expect-acme-title-p.txt
- ../vue-journal-layer/testing/expect-acme-hint-off.txt
- ../vue-journal-layer/testing/expect-acme-breadcrumbs-off.txt
- ../vue-faq-layer/app/utils/faqSchema.ts
- ../vue-faq-layer/tests/faqSchema.test.ts
- ../sm-journal-plugin/classes/related.go
- ../sm-journal-plugin/related_media_test.go
modified:
- ../vue-journal-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, lib/journal-module.ts
- ../vue-journal-layer/app/utils/{htmlGate,seo,postView,types,routes,pagination,view}.ts
- ../vue-journal-layer/app/components/Journal/{PostBody,PostCard,PostList,RelatedPosts,Pagination,CategoryHeader}.vue
- ../vue-journal-layer/app/journal-pages/{PostPage,PostIndexPage,CategoryPage,TagPage,AuthorPage,SearchPage}.vue
- ../vue-journal-layer/app/assets/css/{journal-tokens,journal-base}.css
- ../vue-journal-layer/testing/{smoke-ssr,fixture-api,nuxt-server}.mjs, expect-acme.txt, fixtures/acme/{posts,categories}.json
- ../vue-journal-layer/tests/{htmlGate,seo,routes,pagination,postView,embeds}.test.ts, tests/fixtures/editor-corpus.json
- ../vue-journal-layer/.playground/nuxt.config.ts, .playground/app/app.vue, README.md
- ../vue-menu-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, .playground/nuxt.config.ts, testing/smoke-ssr.mjs, README.md
- ../vue-faq-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, app/components/Faq/Block.vue, .playground/nuxt.config.ts, .playground/app/app.vue, testing/smoke-ssr.mjs, testing/expect.txt, README.md
- ../vue-seo-layer/README.md
- ../sm-journal-plugin/controllers/api/posts.go, config/config.yaml, README.md
deleted:
- ../vue-journal-layer/server/api/__sitemap__/journal.ts, app/utils/sitemap.ts, tests/sitemap.test.ts
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts
- the same four purge files in ../vue-menu-layer and ../vue-faq-layer
key-decisions:
- "Layer breaking changes approved, option A (Jakub, 2026-11-04): journal drops @nuxtjs/seo and its sitemap source; the three layers extend vue-seo-layer; one link per card, breadcrumbs on by default, related titles h3"
- "P33-33 as planned: \"vue-seo-layer\": \"link:../vue-seo-layer\" in each layer's dependencies, lockfiles updated by pnpm install; 33-14 swaps it for the git tag pin"
- "The deprecated POST /api/__journal__/cache/purge keeps its own helpers (app/utils/siteCache.ts): a bare import of vue-seo-layer's .ts library from a server route would be externalized by Nitro in a git-installed app (Node does not strip types under node_modules)"
- "The body link steps are injected into the journal's renderJournalBody by PostBody (auto-imported from vue-seo-layer), so htmlGate stays pure and its node tests import nothing from another package"
- "Auto-links within one text are taken in document order (longer phrase first at the same place) up to autolink_max; each phrase and each URL once"
- "Post page neighbours: the in-category pair when the Journal answers at least one, else the site-wide pair"
- "Scored related posts: posts sharing nothing are never related; in-category neighbours keep previous_post/next_post's direction (previous = nearest newer)"
- "Page-less apps that extend vue-seo-layer set seo.htmlSitemap false (the menu and FAQ playgrounds do)"
patterns-established:
- "Journal smoke grammar: count=, header=, noheader=, headings=strict, cardlinks=N, resolves PATH N; the fixture API also answers the SEO resolve and site endpoints"
- "Cross-layer check: scratch app outside the repositories with link dependencies, fixture APIs of all three layers, the journal smoke harness"
requirements-completed: [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]
coverage:
- id: D1
description: "The journal layer extends vue-seo-layer without @nuxtjs/seo: one canonical and one JSON-LD script per page, the CMS title wins, the post's own title and description only without a CMS answer"
requirement: "D-97"
verification:
- kind: integration
ref: "vue-journal-layer pnpm smoke:ssr (expect-acme.txt tracer lines: count=rel=\"canonical\":1, count=application/ld+json:1, count=<title>:1, /blog/uncategorised-note fallback)"
status: pass
- kind: unit
ref: "tests/seo.test.ts (cmsHeadPresent, journalSiteOrigin)"
status: pass
human_judgment: false
- id: D2
description: "Exactly one H1 in both Journal H1 modes: title h1 and body h1 demoted while content_h1_to_h2 is on; title p.journal-post__title and body h1 kept while off; postTitleTag h1/p give the documented two and zero H1"
requirement: "D-50"
verification:
- kind: integration
ref: "expect-acme.txt (/blog/heading-on, /blog/heading-off), expect-acme-title-h1.txt, expect-acme-title-p.txt"
status: pass
- kind: unit
ref: "tests/htmlGate.test.ts (demoteH1), tests/seo.test.ts (journalPostTitleTag)"
status: pass
human_judgment: false
- id: D3
description: "Heading convention on every fixture page (one h1, no skipped level), cards h2 / h3 under related, category pillar text follows the category row's setting, page >= 2 suffix"
requirement: "D-52"
verification:
- kind: integration
ref: "expect-acme.txt headings=strict lines (16 count=<h1:1 lines), /blog/category/news and /blog/category/plain"
status: pass
human_judgment: false
- id: D4
description: "One link per post card (title only, no read-more, image not a link), stretched-link CSS with the focus ring on the card"
requirement: "D-80"
verification:
- kind: integration
ref: "expect-acme.txt cardlinks=1 on every listing and post page, notcontains=journal-post-card__more"
status: pass
- kind: other
ref: "journal-base.css review (stretched link, :has focus ring)"
status: pass
human_judgment: true
- id: D5
description: "Body links: auto-links in document order up to autolink_max, never in headings or to the page itself; outbound rel kept plus nofollow/sponsored; target, noopener and hint only while new_tab is on, no hint with newTabHint false; images lazy/async with alt=\"\" kept"
requirement: "D-82, D-83, D-71"
verification:
- kind: unit
ref: "vue-seo-layer tests/bodyLinks.test.ts (21 tests, including quotes, entities, javascript: and protocol-relative URLs refused)"
status: pass
- kind: unit
ref: "vue-journal-layer tests/htmlGate.test.ts (rel tokens, renderJournalBody order)"
status: pass
- kind: integration
ref: "expect-acme.txt (hello-acme, heading-off), expect-acme-hint-off.txt"
status: pass
human_judgment: false
- id: D6
description: "SeoBreadcrumbs at the top of journal pages, none with journal.breadcrumbs false (the layout's instance renders the trail, BreadcrumbList unchanged)"
requirement: "D-86"
verification:
- kind: integration
ref: "expect-acme.txt breadcrumb lines, expect-acme-breadcrumbs-off.txt"
status: pass
human_judgment: false
- id: D7
description: "JournalPagination as SeoPagination with journal class names (first, last, gap items, head prev/next); path-form routes registered for the path pagination form; pillar text on page 1 only"
requirement: "D-40, D-41, D-42"
verification:
- kind: integration
ref: "expect-acme.txt /blog/category/news?page=6 and ?page=2 lines"
status: pass
- kind: unit
ref: "tests/routes.test.ts (journalPagedRoutes), tests/pagination.test.ts (pageFromRoute)"
status: pass
human_judgment: false
- id: D8
description: "ItemList on index, category, tag and author pages and the FAQPage node of FAQ blocks, one JSON-LD script per page"
requirement: "D-43"
verification:
- kind: integration
ref: "expect-acme.txt ItemList lines; vue-faq-layer expect.txt (count=\"@type\":\"FAQPage\":1)"
status: pass
- kind: unit
ref: "tests/seo.test.ts (journalItemList), vue-faq-layer tests/faqSchema.test.ts"
status: pass
human_judgment: false
- id: D9
description: "Purge copies removed from the three layers; the shared route from vue-seo-layer and the deprecated journal route keep working"
requirement: "RESEARCH Pitfall 6"
verification:
- kind: integration
ref: "vue-journal-layer testing/purge-check.mjs (post and category) and purge-token-check.mjs; vue-seo-layer pnpm purge:check"
status: pass
human_judgment: false
- id: D10
description: "An app extending journal, menu and FAQ layers renders each page with one resolve call, one canonical and one JSON-LD script"
requirement: "SC-2"
verification:
- kind: integration
ref: "cross-layer scratch app (command and output below), 17 checks"
status: pass
human_judgment: false
- id: D11
description: "Journal related posts: default answer as before; scored mode ranks by shared tags and categories with count 3 to 6 and adds previous_in_category / next_in_category; drafts and future posts never shown to visitors; the layer renders the in-category neighbours"
requirement: "D-82"
verification:
- kind: integration
ref: "sm-journal-plugin TestRelatedDefaultUnchanged, TestRelatedScored, TestRelatedScoredCountClamped (Postgres)"
status: pass
- kind: integration
ref: "expect-acme.txt neighbour lines; tests/postView.test.ts (postNeighbours)"
status: pass
human_judgment: false
duration: 51min
completed: 2026-11-04
status: complete
---
**Layer breaking changes APPROVED: option A; versions confirmed: vue-journal-layer v0.8.0, vue-menu-layer v0.5.0, vue-faq-layer v0.5.0 (breaking minors, tagged in 33-14) (Jakub, 2026-11-04)**
# Phase 33 Plan 09: content layers on the shared SEO layer, heading convention, body links and opt-in related posts Summary
**vue-journal-layer, vue-menu-layer and vue-faq-layer now extend vue-seo-layer. Every page gets one CMS head, one canonical and one JSON-LD graph. The journal layer drops @nuxtjs/seo and keeps exactly one H1 in both Journal H1 modes. Its post cards have one link, its post bodies follow the CMS link and image rules, and it renders the CMS breadcrumbs and pagination. Journal gains opt-in scored related posts and category neighbours, and its default answer is unchanged.**
## Context rulings applied
- **Breaking changes:** option A as approved (see the line at the top).
- The non-breaking guarantees hold:
- the deprecated `POST /api/__journal__/cache/purge` still works;
- `postTitleTag: 'auto'` gives an h1 while the Journal setting is on or absent;
- FAQ `headingLevel` keeps its default of 2 (0 is new and opt-in);
- Journal's scored related posts are opt-in, and the default answer is the same as before (TestRelatedDefaultUnchanged, the API matrix tests).
- No package.json version was changed; 33-14 does that.
- **Framework:** no framework code changed (D-90), and no `summercms` requires were bumped.
- **No SEO code in Journal (D-96):** Journal imports no SEO plugin. All link and SEO logic is in vue-seo-layer.
- **How the layers reference vue-seo-layer:** P33-33 as planned. Each layer's package.json has `"vue-seo-layer": "link:../vue-seo-layer"`, and `pnpm install` updated the lockfiles.
- 33-14 must swap this for the git tag pin before tagging.
- No package came from the registry ("downloaded 0"). The lockfile diffs only move peer resolutions.
- **Not pushed or tagged.** The consumers (grzybyfunkcjonalne, demo, the summercms.io changelog) were not touched.
## Performance
- **Duration:** about 51 min (2026-11-04T12:28:32Z to 13:19:06Z)
- **Tasks:** 4 (a tracer, two TDD tasks and one auto task)
- **Commits:** 13 across 5 repositories (measured with `git rev-list --count <before>..HEAD` per repository)
## Task Commits
All on the default branch, not pushed:
**vue-journal-layer** (1dd3176..adcea4d, 7 commits)
1. Task 1 (tracer): `289d031` feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)
2. Task 2:
- A `1010a60` feat: body links, outbound rules and image attributes in the post body
- B `51e5d67` feat!: one link per post card and the heading convention
- C `98904b8` feat: breadcrumbs, CMS pagination and pillar content on page one
- D `642c25a` feat: ItemList nodes on listing pages
3. Task 3: `ed50826` docs: README for the SEO layer
4. Task 4: `adcea4d` feat: category neighbours on the post page
**vue-seo-layer** (b051bda..7af91fc, 2 commits)
- Task 2: `db44ca4` feat: body link utilities (auto-links, outbound rules, image attributes)
- Task 3: `7af91fc` docs: body link utilities and the app-without-pages note
**vue-menu-layer** (ef3a090..8b5da67, 1 commit)
- Task 3: `8b5da67` feat!: extend the SEO layer and drop the copied purge route
**vue-faq-layer** (1f23340..f7ec4fe, 2 commits)
- Task 3:
- A `cb321e9` feat!: extend the SEO layer and drop the copied purge route
- B `f7ec4fe` feat: FAQPage node and widget headings
**sm-journal-plugin** (0edf6e1..ee3cb26, 1 commit)
- Task 4: `ee3cb26` feat: opt-in scored related posts and category neighbours
The untracked `go.work.sum`, which belongs to another session, was left alone.
The tracer gate ran in interactive end-of-phase mode with automated checks only. Its checks (unit tests, playground build, smoke, both purge checks) passed on the committed tree before expansion.
## Accomplishments
### Journal on the SEO layer (Task 1; D-97, D-50, P33-33 to P33-36)
- `extends: ['vue-seo-layer']`, and `@nuxtjs/seo` is gone from the modules, the dependencies and the `ogImage`, `seo` and `sitemap` keys.
- The journal sitemap source and the copied shared purge route, library, plugin and tests are removed.
- The post title element is decided by `journalPostTitleTag(cfg.postTitleTag, post.content_h1_to_h2)`. While the setting is on, the body is sanitized with `demoteH1` (in both the payload and the sink).
- The fallback meta (post title, excerpt, featured image, article times) applies only while `cmsHeadPresent(answer)` is false.
- `useSiteConfig()` is replaced by `journalSiteOrigin(answer.url, request origin)` in PostPage and AuthorPage.
- The search page is marked noindex through page meta (`seo: { robots: 'noindex, follow' }`), so vue-seo-layer sends both the meta tag and the header.
- New tokens `--journal-post-title-size`, `--journal-post-title-weight` and `--journal-post-title-line-height`; `.journal-post__title` is styled by its class.
### Body links, cards, headings, breadcrumbs, pagination, ItemList (Task 2; D-80 to D-83, D-71, D-52, D-86, D-40 to D-43)
- **vue-seo-layer `bodyLinks.ts`:**
- `autolink` works over text nodes only, with a stack of open tags and an entity guard.
- `applyOutbound` handles rel tokens, target, noopener and the hint inserted before `</a>`.
- `imageAttrs` adds the image attributes.
- Everything works on sanitize-html output, with escaped values.
- **Journal `renderJournalBody`** runs in the L7 order. The steps are injected by PostBody.
- The sanitizer keeps a `rel` made only of the tokens nofollow, sponsored and ugc, mirroring the Go regex exactly. This is the 33-02 follow-up.
- `alt=""` was already kept.
- The editor corpus was copied again from the plugin (17 entries).
- **PostCard:** the title link is the only anchor, the image sits in a `div`, the read-more link is gone, and `headingLevel` takes 2 or 3. The stretched link uses `::after`, and the focus ring is on the card (`:has`). RelatedPosts renders its cards with `heading-level="3"`.
- **Breadcrumbs:** `<SeoBreadcrumbs>` at the top of the post, index, category, tag and author pages while `journal.breadcrumbs` is on.
- **JournalPagination** wraps `<SeoPagination class-prefix="journal-pagination">` with the journal labels.
- **Path pagination form:** with `seo.pagination.form` set to `path`, the module registers `<listing>/<segment>/:page` routes, and the pages read the page through `pageFromRoute`.
- **CategoryHeader:** the pillar text renders on page 1 only, demoted by the category row's `content_h1_to_h2`.
- **ItemList:** `useJournalItemList` adds the node to the index, category, tag and author pages.
### Menu and FAQ layers (Task 3; D-52, D-43, D-71)
- Both extend vue-seo-layer through the link dependency. Their purge copies are removed with `git rm`.
- Their smokes point `NUXT_SEO_API_BASE` at the fixture API, which answers 404, so the layer fails open.
- **FaqBlock:**
- `headingLevel: 0` renders `<p class="faq-block__title">` with the same id and `aria-labelledby`.
- The `schema` prop (default true) adds a FAQPage node through `faqSchema(faq, pageUrl)`: `@id` `<url>#faq`, with the answers as plain text.
- The FAQ smoke harness gained `count=` and repeatable options.
- README updates in all four layers (upgrade notes in the journal, menu and FAQ layers).
### Opt-in related posts (Task 4; D-82, P33-44)
- **Config:** `golem15.journal.related_mode` (`category` | `scored`, default `category`) and `golem15.journal.related_count` (3 to 6, default 3, clamped) are in `config/config.yaml`.
- **Code:** `classes.ReadRelatedSettings`, `ScoredRelatedPosts` (one scoring query), `PrimaryCategoryID` and `CategoryNeighbour`.
- **Show handler:** uses the scored ranking only in scored mode, and only then adds `previous_in_category` and `next_in_category`.
- **Layer:** `postNeighbours` prefers the in-category pair when the Journal answers one.
## Cross-layer check (Task 3)
**Scratch app:** a scratch app outside the repositories, in `/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/cc746d7e-76ce-49f4-ae43-0e1debb2bbea/scratchpad/cross-layer-app`. Nothing from it is committed.
- `package.json` has nuxt 4.4.8 and vue 3.5.35, plus link dependencies on vue-journal-layer, vue-menu-layer and vue-faq-layer.
- `nuxt.config.ts` sets `extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer']` and the playground's i18n.
- `app.vue` renders `<MenuNav code="main">`, `<NuxtPage>` and a `<FaqBlock slug="acme" :heading-level="0">` widget.
**How it runs:** `run-cross.mjs` starts the menu and FAQ fixture APIs, then runs the journal harness:
```
node vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures vue-journal-layer/testing/fixtures/acme --expect expect-cross.txt --env NUXT_MENU_API_BASE=<menu fixture> --env NUXT_FAQ_API_BASE=<faq fixture>
```
**What `expect-cross.txt` checks:**
- For 8 pages (`/blog/hello-acme`, `/blog/heading-on`, `/blog/heading-off`, `/blog`, `/blog?page=2`, `/blog/category/news`, `/blog/tag/release` and `/blog/author/jane-doe`):
- one `application/ld+json` script on every page;
- one `rel="canonical"` on the five pages the CMS fixture answers;
- the menu, the FAQ widget title and the FAQPage node merged into the CMS graph.
- After each page, `resolves <path> 1`.
- At the end, `resolves * 8`.
**Output** (`pnpm exec nuxt build`, then `node run-cross.mjs`, on the final layer trees):
```
build=0
smoke-ssr: ok (17 checks)
```
That is exactly one resolve call per page render.
## Consumer migration notes (for 33-12 and 33-13)
Consumers stay pinned to their current tags until they are migrated. When an app moves to vue-journal-layer v0.8.0, vue-menu-layer v0.5.0 and vue-faq-layer v0.5.0:
**Config and modules**
- Remove `site: { url, name }` and the `@nuxtjs/seo` keys: `ogImage`, `seo.canonicalQueryWhitelist`, `sitemap`, `robots` and `schemaOrg`.
- Set `NUXT_SEO_API_BASE`, or `seo: { cms: false }` for a site without the SEO plugin (planned for summercms.io in 33-12).
- An app that calls `useSiteConfig()`, `useSchemaOrg()` or other `@nuxtjs/seo` composables must add the module itself or move off those composables. An app that adds the module itself gets a second canonical and graph next to the CMS's.
- A site without the SEO plugin loses the `| site.name` title suffix in fallback titles; set `app.head.titleTemplate`.
**Sitemaps and purge**
- Sitemaps now come from the sitemap plugin through vue-seo-layer's root files. `/api/__sitemap__/journal` is gone.
- The purge environment stays the same (`NUXT_CACHE_PURGE_TOKEN`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`, the deprecated `NUXT_SITE_CACHE_*` names). The route is now vue-seo-layer's.
**Markup and CSS**
- **Post cards:**
- `.journal-post-card__more` and the image link are gone, and `.journal-post-card__image` is now a `div`.
- Overrides for those parts have nothing to style any more, and the whole card is clickable.
- Related post card titles are `h3`. CSS on `h2.journal-post-card__title` must target the class instead.
- **Post title:** with the Journal setting off, the title is a `p.journal-post__title`. Style the class or the `--journal-post-title-*` tokens, not `h1`.
- **Breadcrumbs:** they now appear at the top of journal pages. A layout that already renders breadcrumbs sets `journal.breadcrumbs: false`.
- **Pagination:** markup classes stay the same, plus `.journal-pagination__gap` and the page 1 and last-page items. Pagination links follow the CMS form.
- **FAQ:** FAQ blocks add a FAQPage node by default. A page with several FAQ blocks sets `:schema="false"` on all but one.
**Other**
- An app without pages that extends any of these layers sets `seo: { htmlSitemap: false }`. Otherwise every path answers 404 (deferred-items.md).
- The demo app's `/p/**` and `/presentations/**` need `seo.noindexRoutes` (33-08 note), which now comes through every layer.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Adapted to the actual repositories
**1. [Rule 3 - Blocking] The deprecated journal purge route keeps its own helpers.**
- **Plan:** import the purge library from vue-seo-layer.
- **Why changed:** Nitro externalizes a bare import (`vue-seo-layer/server/lib/siteCachePurge.ts`). In a git-installed app that file sits under node_modules, where Node does not strip types, so the server would fail to start.
- **What I did:** the route still uses `app/utils/siteCache.ts`, unchanged. vue-seo-layer's `seo-cache-purge` plugin already empties the SEO caches after it, so the route keeps working (purge-check).
**2. [Rule 3 - Blocking] `useSiteConfig()` was gone.**
- **Found during:** Task 1. It came from @nuxtjs/seo, and PostPage and AuthorPage used it.
- **Fix:** both use the CMS answer's origin, else the request origin (`journalSiteOrigin`).
- **Search page robots:** they came from nuxt-robots through a route rule and now come from page meta. The route rule and the sitemap exclude stay, for apps that add those modules.
**3. Smoke harness.**
- **Expectation lines removed:** the journal expect file dropped its sitemap lines, since the layer no longer serves a sitemap.
- **Fixture API:** answers the SEO resolve and site endpoints, and `resolveCount` was added.
- **Boot probe:** fetches `/_nuxt/` instead of `/`, as the SEO layer's does, so resolve counts stay exact.
- **New checks:** `headings=strict` and `cardlinks=N`. The plan names the rules but not a grammar.
- **Extra expectation files:** beyond the plan's `expect-acme-title-h1.txt`, there are `expect-acme-title-p.txt`, `expect-acme-hint-off.txt` and `expect-acme-breadcrumbs-off.txt`. The `smoke:ssr` script runs all five, and the plan's two verify commands still pass on their own.
**4. Playground changes.**
- The journal playground drops `site`, shows one post per category page (so the 12-page news category has gap items), and has a layout trail shown only while `journal.breadcrumbs` is off.
- The menu and FAQ playgrounds set `seo.htmlSitemap: false`; see deviation 7.
- The FAQ playground adds a `headingLevel` 0 widget.
**5. [Rule 2] The body pipeline takes its steps by injection.**
- **Plan:** "htmlGate runs the L7 order".
- **What I did:** htmlGate has `renderJournalBody(html, options, steps)`, and PostBody passes vue-seo-layer's auto-imported functions.
- **Why:** journal unit tests must not import `.ts` from another package; that breaks once the dependency is a git install (33-14). The order is unit-tested with recording steps.
**6. [Rule 1 - Bug] The FAQ and menu smokes read the default SEO base.** The smokes now point `NUXT_SEO_API_BASE` at the fixture API. Otherwise a local backend on :8096 could answer and make them nondeterministic. This touched `testing/smoke-ssr.mjs` in both layers, which the plan did not list.
**7. [Rule 3 - Blocking] The menu and FAQ playgrounds answered 404 on every path.**
- **Cause:** vue-seo-layer's HTML sitemap pages made Nuxt turn the router on in an app that has only `app.vue`.
- **Fix:** `seo: { htmlSitemap: false }` in both playgrounds, plus README notes in all three layers.
- **Logged in:** deferred-items.md, as a possible SEO-layer fix.
**8. A commit split folded back.**
- **What happened:** commit A (body links) first went in with a stale source-text assertion in `tests/embeds.test.ts`, which matched `expandEmbeds(sanitizeJournalHtml(` only. I had not re-run the unit tests after the last PostBody edit.
- **Fix:** a fixup commit, folded into A with a non-interactive autosquash before anything later was pushed or depended on it.
- **Check:** A's tree was then exported (`git archive`) and passed `npm run test` (182/182).
- **History:** every journal commit is green.
**9. [Rule 2] Extra commit in vue-seo-layer.** `docs: body link utilities and the app-without-pages note`: the plan lists `../vue-seo-layer/README.md` in Task 3 but no seo commit group there.
**10. Grep and test names.**
- `grep -n SeoPagination Pagination.vue` prints two lines, not one: the doc comment and the template.
- The plan's `TestJournalAPIMatrix` names no test. `journal_api_matrix_test.go` holds `TestJournalAPIShowNeighbors`, `TestJournalAPIMassAssignRedactor` and others, which I ran with `-run TestJournalAPI` and which pass. The related-posts commit does not touch that file.
- `posts_helpers.go` is unchanged: the new logic lives in `classes/related.go`.
**11. [Rule 2] An extra related-posts test, and its rules.**
- `TestRelatedScoredCountClamped` covers the 3..6 clamp and an invalid count.
- A post that shares nothing scores 0 and is never related.
- A future-dated published post is excluded alongside the planted draft (T-33-70).
### TDD gate
- **Task 2:** `tests/bodyLinks.test.ts` was written first.
- **RED:** the module was missing.
- **First GREEN run:** 20 of 21. One test failed because the limit was applied in table order; I changed it to document order and documented that.
- **Final:** 21 of 21. The FAQ `faqSchema` tests went RED then GREEN the same way.
- **Task 4:** `related_media_test.go` was written first.
- **RED:** the scored tests failed on the default ranking.
- **One correction on my side:** my expectation for `previous_post` / `next_post` was wrong. `siblingPost` answers previous as the nearest newer post, and I fixed the test before implementing.
- No separate RED commits, because the plan requires green tests at every commit (as in 33-07 and 33-08).
## Issues Encountered
- **The `scripts/check-phase33.sh --layers` stage still refuses** (`test=0 detect=3`, the vitest detector against `node --test` TAP; deferred-items.md, from 33-08). I ran every step of it by hand; the results are under Self-Check.
- **An off-mode category page has two `h1`.** A category whose row has `content_h1_to_h2` false and whose pillar text has an h1 shows two `h1` (the header and the pillar's). That is what the plan's rule produces; the fixture's `/blog/category/plain` asserts it, and the crawler (33-10) reports it.
- **vue-journal-layer `check-git-install.sh` needs updating in 33-14:**
- its lockfile check expects exactly one commit;
- its scratch app lacks the playground options the expectations use.
- Logged in deferred-items.md.
- **The journal fixture API's previous/next direction is the opposite of Go's.** This is pre-existing and logged.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model.
- **T-33-39 and T-33-40:** autolink and applyOutbound only add escaped markup between tags of sanitized output. URLs are limited to site paths and http(s). target and noopener never come from stored HTML. Covered by unit tests.
- **T-33-70:** the scored and neighbour queries apply the published rule for visitors (TestRelatedScored).
- **T-33-SC:** the only new dependency is the first-party `vue-seo-layer` through a local link, and @nuxtjs/seo was removed. The lockfiles are committed.
## Self-Check: PASSED
- **Files:** every created file listed in key-files exists.
- **Commits:** all 13 commits are present in their repositories:
- vue-seo-layer db44ca4 and 7af91fc;
- vue-journal-layer 289d031, 1010a60, 51e5d67, 98904b8, 642c25a, ed50826 and adcea4d;
- vue-menu-layer 8b5da67;
- vue-faq-layer cb321e9 and f7ec4fe;
- sm-journal-plugin ee3cb26.
- **Unit tests:** vue-seo-layer 127/127, vue-journal-layer 187/187, vue-menu-layer 24/24, vue-faq-layer 14/14.
- **Builds:** all four playgrounds build.
- **Smokes:**
- vue-seo-layer `smoke:ssr`: seven runs (31, 64, 11, 11, 7, 3, 7);
- vue-journal-layer `smoke:ssr`: 84, 2, 2, 2, 2;
- vue-menu-layer 17;
- vue-faq-layer 14.
- **Purge and guard:** journal `purge-check` (post and category) 19/19 each and `purge-token-check` 53; vue-seo-layer `purge:check` 18 and 53, `guard:check` 16.
- **Cross-layer check:** 17 checks.
- **sm-journal-plugin:**
- `go vet ./...` clean, `go test -short ./...` green;
- full Docker `go test ./...` green (root package 50 s);
- `TestRelatedScored`, `TestRelatedDefaultUnchanged`, `TestRelatedScoredCountClamped` and the `TestJournalAPI*` matrix pass;
- editor `npm test` 244/244;
- editor bundle unchanged, so `check-dist.sh` was not needed.
- **Acceptance greps:**
- no `@nuxtjs/seo` in the journal package.json or nuxt.config.ts;
- `vue-seo-layer` appears in the package.json and nuxt.config.ts of all three layers;
- no tracked files under `server/lib`, `server/api/__site__` or `server/api/__sitemap__` in the three layers;
- `postTitleTag` in journal-module.ts and PostPage.vue;
- the three `bodyLinks` exports;
- no `journal-post-card__more` or `readMore` in PostCard.vue;
- 16 `count=<h1:1` lines;
- `content_h1_to_h2` in CategoryPage.vue and CategoryHeader.vue;
- `FAQPage` in faqSchema.ts;
- `related_mode` and `related_count` in classes/related.go and config/config.yaml;
- `previous_in_category` in PostPage.vue;
- the related-posts commit does not list journal_api_matrix_test.go.

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,365 @@
---
phase: 33-seo-output-audit-and-content
plan: 10
subsystem: seo
status: complete
tags: [sm-seo-plugin, site-audit, crawler, fetchguard, chromedp, conga, mcp, issues, crawls]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-01 descriptor facts (Tools, TopHeading, Images, Headings, Words, Parents); 33-05 sitemap filter, registry and the CMS issue report; 33-06 schema.Validate and the chromedp approval (v0.21.0)"
provides:
- "classes/crawl: the crawler (fetchguard client on the site host and its www twin, sitemap index and robots.txt seeds, two workers, settings delay and page limit, cancel), the C23 checks, fix routes, fingerprints, storage and recheck"
- "golem15_seo_crawls, golem15_seo_crawl_pages, golem15_seo_crawl_links, golem15_seo_issues, golem15_seo_issue_ignores (migration 202610280005)"
- "CMS-data kinds image_alt and h1_in_content from the content descriptors (classes.Service.ContentIssues), stored with each crawl as source cms"
- "Site audit (golem15.seo.issues) and Crawls (golem15.seo.crawls) screens with P7 issue_details and P6 crawl_summary"
- "Site audit settings tab (crawl_schedule, crawl_max_pages, crawl_delay, crawl_keep, generic_anchors; migration 202610280006), seo:crawl [--scheduled] and the 02:30 schedule entry"
- "MCP tools seo_start_crawl, seo_cancel_crawl, seo_list_crawls, seo_get_crawl, seo_delete_crawls, seo_list_issues (filters), seo_recheck_url, seo_ignore_issues, seo_unignore_issues"
- "Optional raw-versus-rendered comparison through github.com/chromedp/chromedp v0.21.0 (golem15.seo.crawl_chrome_path, crawl_render_max), guarded by resolver pinning and request interception"
affects: [33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 81000
tasks: 4
commits: 2
plan_head_before: e245b54ad994554b4b7cf20ee8cc1ad53dd54f3d
plan_head_after: 835bf3f
sm_seo_plugin_head_before: d38049a9561d892b77f541266f9a7ef6d0086e81
sm_seo_plugin_head_after: b553fc2
sm_seo_plugin_commits: 8
tech-stack:
added:
- "github.com/chromedp/chromedp v0.21.0 (sm-seo-plugin only; with github.com/chromedp/cdproto v0.157.8)"
patterns:
- "A crawl finding names its fix route once (crawl.Route): the record type's fitting tool for content, else a fixed seo_ tool, else fix code with details.what"
- "Issues are keyed by a fingerprint (kind, url, locale, check): ignores survive crawls, a recheck and the pre-crawl CMS refresh keep the row (and id) of an issue found again"
- "The renderer is a seam (crawl.Renderer); tests inject one through a context (crawl.WithRenderer), like fetchguard.WithTransport for the crawler's requests"
key-files:
created:
- ../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/job.go
- ../sm-seo-plugin/classes/crawl/actions.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/models/crawl.go
- ../sm-seo-plugin/models/crawl_page.go
- ../sm-seo-plugin/models/issue.go
- ../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/updates/202610280005_create_golem15_seo_crawls.go
- ../sm-seo-plugin/updates/202610280006_add_golem15_seo_audit_settings.go
- ../sm-seo-plugin/controllers/issues.go
- ../sm-seo-plugin/controllers/crawls.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/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/schedule.go
- ../sm-seo-plugin/crawler_test.go
- ../sm-seo-plugin/crawl_site_test.go
- ../sm-seo-plugin/crawl_admin_test.go
- ../sm-seo-plugin/crawl_render_test.go
- .planning/notes/2026-10-28-chromedp-rendered-crawl.md
modified:
- ../sm-seo-plugin/classes/issues.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/registry.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/controllers/api/issues.go
- ../sm-seo-plugin/controllers/api/settings.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/settings.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/jobs.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/operations_test.go
- ../sm-seo-plugin/seo_tab_test.go
- ../sm-seo-plugin/settings_test.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/plugin.yaml
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- .planning/research/STACK.md
key-decisions:
- "chromedp pinned exactly at v0.21.0 (Jakub 2026-11-04, recorded in 33-06); the decision note was committed before the go.mod change"
- "Chrome's --host-resolver-rules list the site hosts first and MAP * ~NOTFOUND last: Chrome applies the first matching rule, and a leading catch-all made the site host fail with ERR_NAME_NOT_RESOLVED in Chromium (P33-55 had it leading)"
- "One address per host in the resolver rules, IPv4 first (localhost resolving to ::1 first refused the IPv4 test server)"
- "page_status covers errors other than 404/410 (those are broken_link when linked, sitemap_error when listed), so each planted page has exactly one kind"
- "A record's CMS-known links are its primary parent's address (the breadcrumb link): descriptors carry no body links"
- "broken_link, page_status and hreflang_error name seo_create_redirect; sitemap_missing names seo_create_page (seo_set_url_seo for a record page); publisher and site nodes name seo_update_settings"
- "GET /_seo/api/v1/issues and seo_list_issues default to status open (ignored issues left out; ignored or all on request); without ignores the Phase 32 answer is unchanged"
- "Before any crawl the issues list refreshes the stored CMS-data issues on every read; after the first crawl it shows the newest finished, failed or cancelled crawl's issues"
- "Errors-first ordering comes from the Site audit's ListExtendQuery (rank column); the severity column itself is not sortable"
- "List toolbar actions get their own admin operation kind (toolbar_action) in AdminOperations and its parity test"
patterns-established:
- "Stored issue fingerprints: kind + url + locale + check, sha256 truncated, unique per crawl"
requirements-completed: [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]
coverage:
- id: C1
description: "Tracer: an administrator starts a crawl through the API, the job fetches the site with the crawler's User-Agent, GET /issues lists h1_multiple as an error, fix code for the template and the record type's headings tool for an H1 in the record's content"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawler_test.go#TestCrawlerTracer"
status: pass
human_judgment: false
- id: C2
description: "The planted site: every C23 crawl kind found exactly once at its page with its severity and details, nothing on /clean, no request to another host, a private address or plain http"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawler_test.go#TestCrawlerChecks"
status: pass
human_judgment: false
- id: C3
description: "CMS-data issues stored with the crawl as source cms, image_alt and h1_in_content with the descriptor tools, pillar heading_skipped as fix code, every issue routed, ignores surviving the next crawl, fixed pages losing their issues"
requirement: "SC-3"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawler_test.go#TestIssueMerge"
status: pass
human_judgment: false
- id: C4
description: "Staging: the lock's noindex is never reported and pages stay indexable for the other checks"
requirement: "D-24"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawler_test.go#TestStagingCrawl"
status: pass
human_judgment: false
- id: C5
description: "Rendered comparison skipped without a renderer, failed with its note when Render fails, the crawl still finishing with its raw checks"
requirement: "D-70"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawler_test.go#TestRenderSkipped"
status: pass
human_judgment: false
- id: C6
description: "Site audit and Crawls: start and its refusals, errors first, labels and fix column, P7, ignore, row badge, status filter, unignore, bulk ignore, recheck (admin and API), P6 with callouts, the rendered line and kind links, GET crawl render answer, cancel, refused delete of a running crawl"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawl_admin_test.go#TestCrawlAdmin"
status: pass
human_judgment: false
- id: C7
description: "Site audit settings ranges, seeded generic anchors driving generic_anchor, seo:crawl --scheduled off/daily/weekly (ScheduledDue), crawl_keep pruning with pages and issues"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawl_admin_test.go#TestCrawlSchedule"
status: pass
human_judgment: false
- id: C8
description: "The nine MCP tools bound to their routes, delete destructive, MCP start (trigger mcp), list, ignore and unignore; every Site audit and Crawls operation mapped"
requirement: "D-10"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawl_admin_test.go#TestCrawlTools"
status: pass
- kind: unit
ref: "../sm-seo-plugin/operations_test.go#TestEveryAdminOperationHasTool"
status: pass
human_judgment: false
- id: C9
description: "Headless Chromium renders a page with JavaScript-added content; the third-party script and the link-local request are blocked; the profile is removed on Close; the crawl reports content_js check rendered naming /js-only with render done"
requirement: "D-70"
verification:
- kind: integration
ref: "../sm-seo-plugin/crawl_render_test.go#TestChromeRenderer"
status: pass
- kind: unit
ref: "../sm-seo-plugin/crawl_render_test.go#TestRendererGuards"
status: pass
human_judgment: false
- id: C10
description: "Every stored kind has issues.kind_ and issues.sum_ copy in en and pl"
requirement: "SC-3"
verification:
- kind: unit
ref: "../sm-seo-plugin/crawl_admin_test.go#TestIssueKindLabels"
status: pass
human_judgment: false
duration: 75min
completed: 2026-11-04
---
# Phase 33 Plan 10: Site audit crawler Summary
**A built-in crawler (a conga job on a fetchguard client limited to the site host) audits the rendered site for every C23 kind, stores the findings with the CMS-data checks under ignorable fingerprints, routes each issue to an MCP tool or to template code, and compares raw with JavaScript-rendered pages through an optional, guarded chromedp v0.21.0 renderer; Site audit and Crawls screens, a Site audit settings tab, a nightly schedule and nine MCP tools sit on top.**
## Read first: consumers, versions, what is not done
- **sm-seo-plugin is additive for its consumers.** No exported identifier, route, tool, lang key or table of the plugin was removed or renamed. `GET /_seo/api/v1/issues` keeps its Phase 32 keys (`data[].kind/url/locale/details/tool`, `counts`, `total`) and gains `id`, `severity`, `source`, `fix`, `found_at`, `ignored`, `crawl_id`; it now reads stored issues (the CMS checks are refreshed on each read until the first crawl, then the newest crawl's), and it leaves ignored issues out unless `status=ignored|all`. `details.what` is added to fix-code issues. The settings answer gains five `crawl_*`/`generic_anchors` keys. Two migrations (202610280005, 202610280006) add tables and settings columns.
- **New Go dependency in sm-seo-plugin only:** `github.com/chromedp/chromedp v0.21.0` (+ `github.com/chromedp/cdproto v0.157.8`, `github.com/go-json-experiment/json`). It is compiled into every app that mounts the plugin; Chrome itself is optional and operator-installed. `golang.org/x/net` moved from indirect to direct. The framework go.mod is untouched (D-90).
- **plugin.yaml** (kiosk manifest): the description follows the README summary, and `requires.golem15.sitemap` is now `v0.1.2` — kiosk derives that once chromedp is in go.mod (it derived `""` before); `TestPluginManifest` passes.
- **Apps pin sm-seo-plugin as a submodule**, so sm-demo-app and sm-grzybyfunkcjonalne-app still build the old plugin. A scratch go.work that points sm-demo-app at the sibling sm-seo-plugin and sm-sitemap-plugin builds (`go build ./...`, exit 0). With only sm-seo-plugin swapped in, the demo's pinned sitemap lacks the `contract` package — pre-existing since 33-05, resolved by the 33-14 pointer bumps.
- **Not done here (deferred-items.md, WINDOWS #32, #33):** the Site audit status filter cannot default to Open (cabana filters have no default); P7's "Open the page" and Rich Results lines are addresses, not links (partials allow same-origin links only); the settings tile and permission texts wait for 33-11.
- sm-seo-plugin is 23 commits ahead of v0.1.1 (15 before this plan + 8), nothing pushed or tagged.
## Performance
- **Duration:** about 75 min
- **Started:** 2026-11-04T13:50:37Z
- **Completed:** 2026-11-04T15:05Z
- **Tasks:** 4 (all)
- **Files:** 65 changed in sm-seo-plugin (8512 insertions), 2 planning files in summercms.go plus this summary
## Accomplishments
- **Crawler** (`classes/crawl`): robots.txt, the sitemap index and its files, then breadth-first from `/` and the sitemap addresses over same-host links, redirect targets, canonicals and hreflang alternates; `fetchguard.AllowHostsMode` on the site host and its `www` twin (HTTPS, private addresses refused, redirects never followed), `TrustedMode` only from `golem15.seo.crawl_trusted`; two requests at a time with the settings' pause, 10 s, 5 MiB, 1000 links per page, the settings' page limit; cancel keeps what was fetched; one crawl at a time (settings row locked in the start transaction); P33-41 User-Agent.
- **Checks**: all C23 crawl kinds with the kinds-table severities, `schema.Validate` for JSON-LD, the raw HTML against the CMS record (`content_js` check cms: title in an H1, half the word count, the primary parent's link), the rendered comparison (`compareRendered`, check rendered), orphan pages with suggestions from titles and focus keyphrases; CMS kinds `image_alt` and `h1_in_content` from the descriptors; staging noindex never counted.
- **Fix routes** (`crawl.Route`): content findings go to the record type's `Tools` (headings for H1, image_alt, edit), data findings to `seo_set_url_seo`, `seo_update_robots`, `seo_create_redirect`, `seo_create_page`, `seo_update_settings`; everything else is `fix: code` with `details.what`. The registry pages name `seo_update_page` as their edit tool. No stored issue has neither.
- **Storage**: crawls, pages (with main text up to 100 000 characters for 33-11's E10), links, issues with fingerprints, ignores; crawls beyond `crawl_keep` pruned with their rows.
- **Admin**: Site audit (errors first, labels, summary and fix columns, five filters, Ignored badge, P7, Check this address again, Ignore, Stop ignoring, bulk Ignore, Start crawl) and Crawls (newest first, P6 with one status callout, staging and render lines, stats strip and per-kind table with filter links, Cancel, refused delete of a running crawl); navigation Site audit (`gauge`) and Crawls (`globe`) after Pages.
- **Settings**: Site audit tab before Verification with the C20 ranges (`lagoon::validation.between.numeric` on the screen and the API) and the en+pl generic anchors seed.
- **Schedule**: `seo:crawl [--scheduled]`, entry `DailyAt(2, 30)`; weekly runs on Mondays in `app.timezone`.
- **MCP**: nine tools with `AdminOperations` rows; reads are GET, delete is destructive.
- **Rendered comparison**: `NewRenderer` from `golem15.seo.crawl_chrome_path` and `crawl_render_max`; `chromeRenderer` with resolver pinning (`fetchguard.IsPrivateAddr`), Fetch-domain interception (`allowRequest`), fresh profile removed on Close, downloads denied, one tab, 20 s, 5 MiB DOM, default allocator flags (no `--no-sandbox`). Verified against the local Chromium.
## Task Commits
sm-seo-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-seo-plugin`, branch main, not pushed):
1. **Task 1 (tracer)** `c2b61bf` feat: crawler finds a page with two H1 headings and stores the issue (tracer)
2. **Task 2A** `f2c93ae` feat: crawler checks for status, sitemaps, canonical, titles and headings
3. **Task 2B** `8c63233` feat: crawler checks for JSON-LD, hreflang, links, images, raw HTML and the rendered-comparison seam
4. **Task 2C** `edc03ca` feat: orphan pages and merged CMS issues with fix routes
5. **Task 3A+C** `c4f0b4f` feat: Site audit and Crawls screens with their MCP tools
6. **Task 3B** `fc53d93` feat: site audit settings and the nightly crawl
7. **Task 3D** `96963c3` docs: crawler and site audit in README
8. **Task 4B** `b553fc2` feat: rendered comparison through headless Chrome
summercms.go (master):
- **Task 4A** `e9504b1` docs(33-10): chromedp decision note and STACK row (before the go.mod change)
- `835bf3f` docs(33-10): resolver rule order in the chromedp note
## Decisions Made
See `key-decisions` above. The rule-order and IPv4 decisions were checked against Chromium: with `MAP * ~NOTFOUND` first, `TestChromeRenderer` failed with `ERR_NAME_NOT_RESOLVED`; with it last, it passes.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Resolver rule order**
- **Found during:** Task 4
- **Issue:** P33-55 (and the first version of the decision note) put `MAP * ~NOTFOUND` first; Chrome applies the first matching rule, so the site host itself did not resolve.
- **Fix:** site host rules first, catch-all last; the note corrected in `835bf3f`.
- **Files modified:** `classes/crawl/render.go`, `.planning/notes/2026-10-28-chromedp-rendered-crawl.md`
- **Commit:** `b553fc2`, `835bf3f`
**2. [Rule 1 - Bug] IPv6-first resolution refused the site**
- **Found during:** Task 4 (`TestChromeRenderer`: `ERR_CONNECTION_REFUSED`)
- **Issue:** `localhost` resolved to `::1` first while the server listened on IPv4.
- **Fix:** the resolver rule takes the first IPv4 address, else the first.
- **Commit:** `b553fc2`
**3. [Rule 3 - Blocking] Admin screens and MCP tools in one commit**
- **Found during:** Task 3
- **Issue:** `TestEveryAdminOperationHasTool` fails for any admin operation without its tool, so commit A (screens) cannot be green without commit C (tools).
- **Fix:** groups A and C landed together as `c4f0b4f`; B and D followed. Commit order: A+C, B, D.
**4. [Rule 3 - Blocking] Renderer seam in the tracer commit**
- The tracer's `crawler.go` already calls the `Renderer` interface, so `render.go` (interface, statuses, `WithRenderer`) landed in `c2b61bf`; `compareRendered` and the wiring of findings landed in 2B as planned.
**5. [Rule 3 - Blocking] Exported renderer test seam**
- The crawler tests live in the external `seo_test` package, so the fake renderer is injected through `crawl.WithRenderer(ctx, r)`, a context value settable only from Go code (like `fetchguard.WithTransport`), not an unexported option.
**6. [Rule 3 - Blocking] plugin.yaml followed the README summary**
- `96963c3` changed the README's first sentence, which kiosk's manifest check compares; the README commit was amended (local, unpushed) with the regenerated description so it passes on its own. Task 4 then set `requires.golem15.sitemap: v0.1.2`, what kiosk derives with chromedp in go.mod.
**7. [Rule 3 - Blocking] Tests following new screens**
- `TestLangKeysComplete` skips value-built `kind_`/`sum_` keys like it skips `via_`/`source_` (`TestIssueKindLabels` checks every kind instead); `TestSettingsTypeTabs` expects the new Site audit tab before Verification.
**8. [Rule 2 - Missing] `cancelled_by` column**
- P6's cancelled text names who cancelled; P33-37's table had no column for it.
**9. [Rule 2 - Missing] Recheck keeps the issue's row**
- The record action reloads the preview of the same issue, so a recheck (and the pre-crawl CMS refresh) keeps the rows of issues found again and only deletes or adds the difference.
### Plan assumptions adapted
- **Filter default (C23):** cabana filters take no default; the list shows ignored issues badged, the API defaults to open (deferred item, WINDOWS #32).
- **External links in P7:** the partial allowlist drops cross-origin `href`; the page address and the Rich Results URL are shown in DM Mono (deferred item, WINDOWS #33).
- **P6 kind links** filter the Site audit by kind only; the list shows the current crawl and has no crawl filter.
- **Severity sorting:** errors first comes from `ListExtendQuery`'s `ORDER BY rank`; `defaultSort` is `kind` and the severity column is not sortable.
- **CMS links:** descriptors have no body links, so content_js check cms checks the primary parent's address (the breadcrumb link).
- **`compareRendered(raw, rendered *PageFacts, hosts []string)`** takes the site hosts to tell same-host links apart.
- **The seo-page Tools** sit in `classes/registry.go`, where its descriptor is (menu_item_types.go only registers it).
- **Acceptance greps that assume flat lang keys** (`grep -c "issues.kind_"`) count 0 in the nested YAML; each file has 36 `kind_` and 36 `sum_` keys under `issues:` (`TestIssueKindLabels`).
- **Note-before-go.mod check:** the note's first commit (`e9504b1`, ct 1793803438) precedes the first chromedp go.mod commit (`b553fc2`, ct 1793804316); `git log -1` on the note now prints the later correction commit `835bf3f`.
## Issues Encountered
- A shell `cp` alias (`cp -i`) left a background experiment waiting for input after it restored `render.go`; the file was restored with Python, the waiting process killed, and the build re-checked.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model: the new routes are management routes behind `golem15.seo.manage`; outbound HTTP goes through fetchguard; Chrome is held to the same hosts and addresses.
## Verification run
In `/media/nvme/dev/golem15/summercms.io/summercms/sm-seo-plugin`, after every commit: `go vet ./...` and `go test -short ./...` green; the full Docker suite (`go test ./...`) green before each code commit (about 100 s at the end).
- `go test ./... -run 'TestCrawlerTracer|TestCrawlerChecks|TestIssueMerge|TestStagingCrawl|TestRenderSkipped|TestIssues' -v`: all PASS.
- `go test ./... -run 'TestCrawlAdmin|TestCrawlSchedule|TestCrawlTools|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestCrawlerChecks' -v`: all PASS.
- `go test ./... -run 'TestChromeRenderer|TestRenderSkipped|TestCrawlerChecks|TestRendererGuards' -v`: all PASS; `TestChromeRenderer` ran against `/usr/bin/chromium` (not skipped).
- Acceptance greps: `fetchguard.NewClient`/`AllowHostsMode` in fetch.go; `golem15.seo.crawl` in jobs.go and job.go; `crawl_trusted`, `crawl_chrome_path`, `crawl_render_max` once each in config.yaml; `schema.Validate` in checks.go; `func compareRendered(` and `type Renderer interface` once each; eight tool names in mcp.go; `seo:crawl` in schedule.go and commands.go; the two controller IDs in admin_navigation.go; `render_skipped|render_done|render_failed` in both phrasebooks; `host-resolver-rules` and `fetchguard.IsPrivateAddr` in render_chrome.go; no `no-sandbox`; no `crawl_chrome_path` under models/ or settings_write.go; `github.com/chromedp/chromedp` in go.mod, STACK.md and the note.
`scripts/check-phase33.sh` in summercms.go (`--layers` skipped, known broken since 33-08):
- `--plugins`: passed (vet and short tests of sm-sitemap-plugin, sm-seo-plugin, sm-journal-plugin; vitest 87/87 in sm-seo-plugin, 244/244 in sm-journal-plugin).
- `--consumers`: passed (the four apps build with their pinned submodules; see "Read first" for the scratch build against this plugin).
- `--self-test --go --admin --docs --marker --docker --additive --compat`: every stage PASS (`docker` ran the three plugins' Postgres suites; `additive`: 4 packages against v0.6.0). `compat` printed refusals for sm-feedback-plugin, sm-presentations-plugin and sm-quizzes-plugin (their own replace layout resolves the framework to nothing, at the base tag too) and passed.
## User Setup Required
None for the crawler. For the rendered comparison an operator installs Chrome or Chromium on the server and sets `golem15.seo.crawl_chrome_path` (absolute path); a staging or local site over http needs `golem15.seo.crawl_trusted: true`.
## Next Phase Readiness
- 33-11 can read `golem15_seo_crawl_pages.text` (main text, 100 000 characters) for E10 suggestions, add Auto-links between Pages and Site audit in the navigation, and replace the settings tile and permission texts.
- 33-14: bump the sm-seo-plugin pointer in the apps together with sm-sitemap-plugin (the demo's pinned sitemap lacks `contract`), tag the plugin, and note the chromedp dependency and the two migrations in the release notes.
## Self-Check: PASSED
- Files: every created file listed above exists in sm-seo-plugin or summercms.go.
- Commits: `c2b61bf`, `f2c93ae`, `8c63233`, `edc03ca`, `c4f0b4f`, `fc53d93`, `96963c3`, `b553fc2` in sm-seo-plugin; `e9504b1`, `835bf3f` in summercms.go.

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,389 @@
---
phase: 33-seo-output-audit-and-content
plan: 11
subsystem: seo, media
status: complete
tags: [sm-seo-plugin, auto-links, internal-links, e10, mcp, lagoon, attach, webp, gen2brain, sm-journal-plugin, conga, image-variants, vue-journal-layer, picture, srcset]
requires:
- phase: 33-seo-output-audit-and-content
provides: "33-05 Links tab and the four-place settings pattern; 33-06 element bundle, the seo_graph widget pattern and the gen2brain/webp approval (v0.6.4); 33-09 vue-seo-layer autolink(), the consumer of autolinks/autolink_max; 33-10 crawl pages (main text) and links, the toolbar action operation kind"
provides:
- "golem15_seo_autolinks and settings autolink_max (migration 202610280007); ok resolve answers carry autolinks [{phrase, url}] and autolink_max"
- "SEO › Auto-links (golem15.seo.autolinks) with Add focus keyphrases; /_seo/api/v1/autolinks routes; MCP seo_list_autolinks, seo_create_autolink, seo_update_autolink, seo_delete_autolinks, seo_autolinks_from_keyphrases"
- "E10 golem15-seo-links (widget action seo_links) and GET /_seo/api/v1/links; MCP seo_get_link_suggestions"
- "Framework: attach.VariantKey and package modules/lagoon/attach/webp (Encode, Variant, Quality, Format, ContentType); github.com/gen2brain/webp v0.6.4"
- "sm-journal-plugin: golem15_journal_image_variants (migration 202610280002), job golem15.journal.image_variants, journal:image-variants [--all], config golem15.journal.image_widths, featured_images[].variants"
- "vue-journal-layer: app/utils/srcset.ts (imageSrcsets) and <picture> with WebP in JournalFeaturedImage"
affects: [33-12, 33-13, 33-14]
actuals:
tokens: 46000
tasks: 4
commits: 2
plan_head_before: 3bfdc8616e8bda172a5c835d3c6ac30548c4616a
plan_head_after: 5c07224
sibling_repos:
sm-seo-plugin: {head_before: b553fc2, head_after: 49cc241, commits: 3}
sm-journal-plugin: {head_before: ee3cb26, head_after: 4f5bd19, commits: 1}
vue-journal-layer: {head_before: adcea4d, head_after: 1080a3f, commits: 1}
tech-stack:
added:
- "github.com/gen2brain/webp v0.6.4 (framework go.mod; only modules/lagoon/attach/webp imports it; its one dependency github.com/ebitengine/purego v0.10.1 was already in the module graph)"
patterns:
- "A generic media helper lives in a framework sub-package so that only binaries that import it link the encoder (attach/webp next to attach)"
- "Auto-link writes record history under subject autolink, and its purge slug is * (a phrase may appear in any text)"
- "A widget's suggestions read the last finished crawl; phrase matching shares crawl's normal form (classes.NormalText)"
key-files:
created:
- ../sm-seo-plugin/models/autolink.go
- ../sm-seo-plugin/models/autolink/columns.yaml
- ../sm-seo-plugin/models/autolink/fields.yaml
- ../sm-seo-plugin/updates/202610280007_create_golem15_seo_autolinks.go
- ../sm-seo-plugin/classes/autolinks.go
- ../sm-seo-plugin/classes/linkgraph.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/assets/src/elements/links.ts
- ../sm-seo-plugin/tests/elements/links.test.ts
- ../sm-seo-plugin/linking_test.go
- .planning/notes/2026-10-28-webp-gen2brain.md
- modules/lagoon/attach/variant.go
- modules/lagoon/attach/webp/webp.go
- modules/lagoon/attach/webp/webp_smoke_test.go
- modules/lagoon/attach/webp/export_test.go
- ../sm-journal-plugin/classes/image_variants.go
- ../sm-journal-plugin/console/image_variants.go
- ../sm-journal-plugin/models/image_variant.go
- ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go
- ../sm-journal-plugin/jobs.go
- ../vue-journal-layer/app/utils/srcset.ts
- ../vue-journal-layer/tests/srcset.test.ts
modified:
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/classes/crawl/fix.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/models/revision.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/api/settings.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/operations_test.go
- ../sm-seo-plugin/seo_tab_test.go
- ../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
- go.mod
- go.sum
- modules/lagoon/README.md
- docs/database/attachments.md
- .planning/research/STACK.md
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/posts.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/types.ts
- ../vue-journal-layer/app/assets/css/journal-base.css
- ../vue-journal-layer/testing/fixtures/acme/posts.json
- ../vue-journal-layer/testing/expect-acme.txt
- ../vue-journal-layer/README.md
key-decisions:
- "gen2brain/webp pinned exactly at v0.6.4 (Jakub 2026-11-04, recorded in 33-06); the decision note (1bb2ecf) is older than the go.mod change (5c07224)"
- "The gen2brain/webp fallback is libwebp compiled to WASM and transpiled to Go by wasm2go (libwebp.go, about 2.7 MB of generated Go). There is no WASM runtime and no wazero; the first choice is the system libwebp through purego"
- "Importing purego makes a binary dynamically linked against glibc even with CGO_ENABLED=0; -tags nodynamic keeps it static (transpiled encoder only). Recorded in the note, the lagoon README, docs/database/attachments.md and deferred-items for 33-14"
- "An auto-link's locale is stored as '' for every locale (NOT NULL), not NULL as P33-45 wrote; the unique index is (lower(phrase), locale), and the save refuses a phrase of every locale that meets the phrase of one locale, both ways"
- "Auto-link history uses subject autolink with purge slug * (a phrase can appear on any page); auto-links have no undo, like registry pages"
- "autolinks and autolink_max are omitted on non-ok answers and from the sitemap filter's production resolve; with autolink_max 0 the answer carries no phrases"
- "E10 is left out for the kinds collection and profile and for the page registry, because Words is a per-record fact (deferred item)"
- "E10's inbound count is the distinct pages linking here, the outbound count the distinct site pages it links to; a page missing from the crawl gets 0 and 0 and is not called an orphan; suggestions skip pages already linked either way"
- "WebP variants are written in mode exact at the original's aspect ratio for each width no larger than the original; every variant row carries the original's source_width and source_height, so the only new API key is variants"
- "golem15_journal_image_variants has no foreign key to system_files (the plugin's migration tests run without framework tables); the API delete removes the rows"
patterns-established:
- "Framework media add-ons: a separate sub-package (attach/webp) keeps a heavy dependency out of binaries that do not import it"
requirements-completed: [SC-2, SC-3, D-82, D-85, D-71, D-10]
coverage:
- id: L1
description: "Tracer: an all-locale phrase reaches another page's resolve answer with autolink_max 3, never the target page, never a disabled or other-locale phrase; autolink_max 0 answers none; 21 refused; three history rows"
requirement: "D-82"
verification:
- kind: integration
ref: "../sm-seo-plugin/linking_test.go#TestAutolinksTracer"
status: pass
human_judgment: false
- id: L2
description: "Auto-links screen and API: the refusals (short phrase, phrase taken naming its URL, javascript:, data:, protocol-relative, ftp, whitespace), labels, the disabled row state, the active and locale filters, Add focus keyphrases twice (second adds nothing), MCP origin, update, delete, history vias"
requirement: "D-82, D-10, T-33-50"
verification:
- kind: integration
ref: "../sm-seo-plugin/linking_test.go#TestAutolinks"
status: pass
human_judgment: false
- id: L3
description: "E10 data: null counts before a crawl; after it, inbound/outbound/orphan and both suggestion lists with public and admin addresses; the widget action answers the same; seo_links is in the SEO tab after seo_graph"
requirement: "D-82"
verification:
- kind: integration
ref: "../sm-seo-plugin/linking_test.go#TestLinkSuggestions"
status: pass
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestSEOTab"
status: pass
human_judgment: false
- id: L4
description: "E10 element: counters, orphan callout, both lists, notCrawled, none, checking line with aria-busy, danger line on failure, Copy address and the copied status for 2 s, hidden button without a clipboard, locale switch"
requirement: "D-82"
verification:
- kind: unit
ref: "../sm-seo-plugin/tests/elements/links.test.ts"
status: pass
human_judgment: false
- id: L5
description: "MCP parity: six new tools, every Auto-links and E10 operation mapped, reads are GET, the delete is destructive"
requirement: "D-10"
verification:
- kind: unit
ref: "../sm-seo-plugin/operations_test.go#TestEveryAdminOperationHasTool"
status: pass
- kind: unit
ref: "../sm-seo-plugin/operations_test.go#TestMCPReadToolsAreGET"
status: pass
human_judgment: false
- id: M1
description: "WebP encoder: RIFF/WEBP output, VariantKey rules (webp original appends), a variant next to its thumbnail written once, a failed encode leaves no file, the documented example"
requirement: "D-85"
verification:
- kind: unit
ref: "modules/lagoon/attach/webp/webp_smoke_test.go"
status: pass
human_judgment: false
- id: M2
description: "Journal: the upload answers before any encoding and queues the job; the job writes 480 and 800 (1200 wider than the 1000 px original) with WebP copies; the API's variants; a second run writes nothing; an admin save queues only for an image lacking a width; the API delete removes rows and blobs"
requirement: "D-85"
verification:
- kind: integration
ref: "../sm-journal-plugin/related_media_test.go#TestImageVariants"
status: pass
human_judgment: false
- id: M3
description: "Layer: srcset strings from variants plus the original's width; <picture> with image/webp, width, height, alt, eager with fetchpriority high on the post, lazy in cards; an image without variants renders as before"
requirement: "D-85, D-71"
verification:
- kind: unit
ref: "../vue-journal-layer/tests/srcset.test.ts"
status: pass
- kind: integration
ref: "../vue-journal-layer/testing/expect-acme.txt (featured image lines, smoke-ssr 87 checks)"
status: pass
human_judgment: false
duration: 75min
completed: 2026-11-04
---
# Phase 33 Plan 11: auto-links, internal link suggestions and WebP featured images Summary
**Editors keep a phrase → URL table that every ok resolve answer carries for its locale (minus the page itself), with a screen, a keyphrase feed and MCP tools; the SEO tab shows each page's inbound and outbound links from the last crawl with linking suggestions (E10); featured images get thumbnails at 480/800/1200 and a WebP copy of each from a Journal conga job, encoded in Go by the framework's new `attach/webp` package (github.com/gen2brain/webp v0.6.4), and the journal layer serves them as `<picture>` with srcset.**
## Read first: consumers, versions, what is not done
- **Everything is additive.** No exported identifier, route, tool, lang key, table or answer key was removed or renamed in any repository.
- sm-seo-plugin: the resolve answer gains `autolinks` and `autolink_max` on ok answers; the settings answer gains `autolink_max`. New routes `GET/POST /_seo/api/v1/autolinks`, `PUT /autolinks/{id}`, `DELETE /autolinks?ids=`, `POST /autolinks/from-keyphrases`, `GET /links?url=`. Six MCP tools. Migration 202610280007 (table and settings column).
- Framework: `attach.VariantKey` and the new package `modules/lagoon/attach/webp`. The gate's additive check passes (5 packages against v0.6.0, `modules/lagoon/attach` +1 line).
- sm-journal-plugin: `featured_images[].variants` (new key on every entry, `[]` until made), migration 202610280002, job `golem15.journal.image_variants`, command `journal:image-variants`, config key `golem15.journal.image_widths`. The plugin now registers jobs (`pact.HasJobs`).
- vue-journal-layer: `JournalFeaturedImage` gains the optional props `image` and `sizes`. Without variants its markup is byte-for-byte as before.
- **New dependency: `github.com/gen2brain/webp` v0.6.4, pinned exactly**, in the framework go.mod (and indirect in sm-journal-plugin's). The only package that imports it is `modules/lagoon/attach/webp`. Its one dependency, `github.com/ebitengine/purego` v0.10.1, was already in the module graph, so no other module was added. The module source confirms the fallback is wasm2go-transpiled Go, not wazero.
- **Linking consequence, which 33-14 should decide on.** A binary that imports `attach/webp` (any app mounting sm-journal-plugin) is dynamically linked against glibc even with `CGO_ENABLED=0`, because of purego. It needs `ld-linux-x86-64.so.2`, `libc.so.6`, `libdl.so.2` and `libpthread.so.0`. It runs on the Debian servers but not in scratch or musl images. `-tags nodynamic` gives a static binary with the slower transpiled encoder. Installing `libwebp-dev` enables the fast path. The note, the lagoon README, the attachments docs and deferred-items all say this.
- **Not pushed or tagged.** sm-seo-plugin is 26 commits ahead of v0.1.1. sm-journal-plugin, vue-journal-layer and summercms.go have unpushed phase-33 commits. The apps still pin the old plugin submodules, and 33-14 bumps them.
- **Settings tile and permission texts** (33-10 deferred item) now carry the UI-SPEC texts in en and pl, and the deferred item is marked resolved.
## Performance
- **Duration:** about 75 min (2026-11-04, about 15:05Z to 16:20Z)
- **Tasks:** 4 (a tracer, two TDD tasks, one auto task)
- **Commits:** 7 across 4 repositories (sm-seo-plugin 3, summercms.go 2, sm-journal-plugin 1, vue-journal-layer 1); measured with `git rev-list --count <before>..HEAD` per repository
## Task Commits
**sm-seo-plugin** (`/media/nvme/dev/golem15/summercms.io/summercms/sm-seo-plugin`, main, b553fc2..49cc241)
1. Task 1 (tracer): `6203aec` feat: auto-link phrases in the resolve answer (tracer)
2. Task 2A: `ab4e834` feat: auto-links screen, keyphrase feed and MCP tools
3. Task 2B: `49cc241` feat: internal links widget with counts and suggestions
**summercms.go** (master, 3bfdc86..5c07224)
4. Task 3A: `1bb2ecf` docs(33-11): WebP decision note and STACK row
5. Task 3B: `5c07224` feat(lagoon): WebP variants of attachment thumbnails
**sm-journal-plugin** (main, ee3cb26..4f5bd19)
6. Task 4A: `4f5bd19` feat: image variants job and variants in the post API
**vue-journal-layer** (main, adcea4d..1080a3f)
7. Task 4B: `1080a3f` feat: WebP and srcset for featured images
## Accomplishments
- **Auto-links (D-82).** The table has a phrase, a URL (a `/` path or an http(s) address), a locale or all, an active switch, an origin (manual, keyphrase or mcp) and a note. Every write is checked and recorded in history, which purges the site cache with slug `*`.
- `Service.ForPage` lists the active phrases of a locale, longest first. It drops phrases that link to the page itself, as a path or as an absolute URL on the site host or its www twin.
- The resolver adds `autolinks` and `autolink_max`, and vue-seo-layer's `autolink()` (33-09) applies them.
- **Auto-links screen (C22).** It sits after Pages with the icon `wand-sparkles`. The list shows 50 rows, has checkboxes and setup, and its toolbar holds create, delete and Add focus keyphrases.
- Columns: phrase, links to, language (All languages or the code), origin label, updated. A row gets the disabled state ("Off") when the phrase is inactive.
- Filters: language, origin, and the Active only switch.
- Form: phrase and URL, language (empty option All languages) and Active, note.
- Refusals: `phrase_short`, `phrase_taken` (names the URL), `url_invalid`.
- Add focus keyphrases turns the SEO entries' focus keyphrases into phrases (one per locale, origin keyphrase) and toasts the count (CLDR plural) or `keyphrases_none`.
- **E10 golem15-seo-links (C19).** The `seo_links` widget sits in the SEO tab after `seo_graph`, on update only.
- It shows the counters (`.summer-stats`) with the crawl time, the orphan callout, "Pages you could link to from this text" with Copy address, and "Pages that could link here" with admin links.
- Empty states: `notCrawled` before the first crawl, `none` under an empty list.
- Loading and errors: the checking line with `aria-busy`, a danger line on failure. Copy address falls back to the selectable URL when the clipboard is unavailable, and the element follows the ML locale switch.
- `classes/linkgraph.go` reads the last finished crawl's pages and links. The crawler's orphan suggestions now share `classes.NormalText`.
- **Framework WebP (P33-42).**
- `attach.VariantKey(thumbKey, format)` replaces the thumbnail's extension, or appends the format when the thumbnail already has that extension.
- `webp.Encode` encodes at quality 80.
- `webp.Variant` calls ThumbKey first and returns early when the variant exists. It encodes in memory, stores `image/webp`, and deletes the key on a write or close error.
- Docs section "WebP variants" in docs/database/attachments.md, with a synced `src=` example.
- **Journal image variants.**
- The job runs with three attempts. It makes each configured width no larger than the original (mode exact, the original's aspect ratio) through ThumbKey and `webp.Variant`, then inserts rows with ON CONFLICT DO NOTHING. It skips originals that are missing, don't decode or are over the pixel limit.
- It is queued after an API upload and after an admin save of a post whose images lack a width. The upload never encodes, and a queue failure is only logged.
- `journal:image-variants [--all]` backfills.
- `featured_images[].variants` is loaded with one query per post.
- The API delete removes a file's rows.
- **Layer.**
- `imageSrcsets` builds the WebP and original-format srcsets, adds the original when it is wider than every variant, works out the reserved size, and escapes addresses.
- `JournalFeaturedImage` renders `<picture>`: cards pass `sizes="(min-width: 48rem) 33vw, 100vw"` and load lazily; the post page passes `sizes="(min-width: 46rem) 46rem, 100vw"` with eager loading and `fetchpriority="high"`.
- `picture` is styled as a block element.
## Decisions Made
See `key-decisions` above.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Auto-link locale stored as '' instead of NULL**
- **Found during:** Task 1
- **Issue:** P33-45 has `locale null = all`. The admin dropdown's empty option posts "", and a nullable column would need a custom binding type.
- **Fix:** `locale TEXT NOT NULL DEFAULT ''`, unique `(lower(phrase), locale)`. The save check treats a phrase of every locale as meeting each locale, both ways. "all" is accepted as an alias in the API and the filter.
- **Commit:** `6203aec`
**2. [Rule 2 - Missing] Auto-link history purges every page**
- **Issue:** `purgeSlug` would have used the autolink's target `url` as the purge slug, but a phrase changes the pages that contain it, not its target.
- **Fix:** `SubjectAutolink` purges `*`.
- **Commit:** `6203aec`
**3. [Rule 3 - Blocking] E10 omitted by kind, not by Words**
- **Issue:** `Words` is per record and locale (`pact.ContentLocale`), so the per-type SEO tab cannot read it.
- **Fix:** `hasBody(key, kind)` leaves E10 out for collection, profile and the page registry. Logged in deferred-items with a framework fix (`ContentDescriptor.HasBody`).
- **Commit:** `49cc241`
**4. [Rule 2 - Missing] E10 checking and failure copy**
- **Issue:** The E10 copy table has no loading or failure text, and E3's `checking` says "Checking for duplicates…".
- **Fix:** Added `linksChecking` ("Loading the internal links…" / "Wczytywanie linków wewnętrznych…") and `linksFailed` ("The internal links could not be loaded. Try again." / "Nie udało się wczytać linków wewnętrznych. Spróbuj ponownie."). E10 keys are prefixed `links*` because `none` and `links` already exist in the shared dictionary.
- **Commit:** `49cc241`
**5. [Rule 2 - Missing] The original's size in the variants**
- **Issue:** The layer needs the original's width for the srcset and the reserved size, and `system_files` has no dimensions.
- **Fix:** Each row and each `variants` entry carries `source_width` and `source_height`, so `variants` stays the only new API key.
- **Commit:** `4f5bd19`
**6. [Rule 3 - Blocking] No foreign key from the variants table to system_files**
- **Issue:** The plugin's own migration tests (updates, models) run without the framework's tables, and the foreign key broke five tests.
- **Fix:** Plain `file_id`. The API delete removes the rows; leftovers from admin removals are logged in deferred-items.
- **Commit:** `4f5bd19`
**7. [Rule 1 - Bug] The admin save re-queued the job for complete images**
- **Issue:** The "missing" filter counted variant rows against the number of widths, but widths wider than the original never get a row.
- **Fix:** An image counts as missing only when it lacks a width no larger than its recorded source width.
- **Commit:** `4f5bd19`
**8. [Rule 3 - Blocking] Docs code blocks need a `src=` snippet**
- **Issue:** `docs:build --check` refuses a Go block without `src=`.
- **Fix:** Added `ExampleVariant` to the webp tests (external test package, plus `export_test.go` for the encoder seam), referenced with `src=` and synced.
- **Commit:** `5c07224`
### Plan assumptions adapted
- **Files that did not apply.** `modules/lagoon/attach/README.md` does not exist, so attach is documented in `modules/lagoon/README.md`. `models/registry.go` and `updates/registry.go` (both plugins) needed no change, because new files register themselves in `init`. `classes/settings_write.go` also carries the `autolink_max` range through `Settings.AuditProblems` (0 to 20, `lagoon::validation.between.numeric`).
- **The plan's description of the fallback.** It says "WASM via wazero". The v0.6.4 source is wasm2go-transpiled Go, and the decision note says so.
- **`TestJournalAPIMatrix` does not exist.** The plan's verify names it, but the matrix file holds several tests (`TestJournalAPIShowNeighbors`, `TestJournalPerPageClamp`, …). The full Journal suite (`go test ./...`, Docker) ran green instead.
- **Acceptance grep `golem15.seo.autolinks` in admin_navigation.go.** The navigation uses the constant `controllers.AutolinksControllerID`, so a comment on the nav entry names the controller ID.
- **TestAutolinks reads list rows by phrase, not by position.** cabana sorts the phrase column in the database's byte order. Logged as a deferred item.
## Issues Encountered
- `gofmt` turned `''` in a Go comment into a typographic quote. The comment now reads "empty".
- The SSR expectation grammar splits on ` type=`, so the `<source type="image/webp"` lines start their `contains=` after `type`.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model.
- T-33-50: URLs are restricted to paths and http(s), tested with javascript:, data:, protocol-relative and ftp addresses.
- T-33-51: the pixel limit is checked from the header before any decode, widths wider than the original are skipped, and encoding runs in a job with attempts.
- T-33-52: the version is pinned, and the note and STACK row were committed before go.mod.
- T-33-53: every new route sits behind `manage()` with `golem15.seo.manage`, and the parity test covers the tools.
## Verification run
- **sm-seo-plugin:** `go vet ./...`, `go test -short ./...` and the full Docker suite (`go test ./...`, about 100 s) were green before each of the three commits.
- `TestAutolinksTracer`, `TestAutolinks`, `TestLinkSuggestions`, `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET` and `TestSEOTab` all PASS.
- `npm run test`: 93/93 (9 files). `npm run build` then `scripts/check-dist.sh`: "assets/dist matches a fresh build".
- Acceptance greps: two resolver json tags; `autolink_max` in settings_write.go and fields.yaml; six tool names in mcp.go; `golem15-seo-links` in index.ts; `golem15.seo.autolinks` in admin_navigation.go.
- **summercms.go:**
- `go vet ./...` and `go test -short ./...` green. `go test ./modules/lagoon/...` (Docker): lagoon, attach and attach/webp all ok.
- `summer docs:build --check`: no problems. `go test ./cmd/summer -run TestDocsTree`: ok.
- `grep -rln gen2brain modules --include=*.go` lists only `modules/lagoon/attach/webp/webp.go`.
- Note before go.mod: `1bb2ecf` is the parent of `5c07224`, the first gen2brain change to go.mod.
- **sm-journal-plugin:** `go vet ./...`, `go test -short ./...` and the full Docker suite all green. `TestImageVariants` PASS. `GOWORK=off go build ./... && go vet ./...` also green (go.mod has the framework replace). `TestCommandsDocumented` logs nothing for `journal:image-variants`.
- **vue-journal-layer:**
- `npm run test`: 192 pass, 0 fail (srcset.test.ts: 5).
- `npm run build:playground`: complete. `npm run smoke:ssr`: ok (87, 2, 2, 2, 2 checks).
- TypeScript: the package has no typecheck script. `tsc --noEmit -p .playground/.nuxt/tsconfig.app.json --allowImportingTsExtensions` reports 3 pre-existing errors in untouched files (useJournalItemList.ts, siteCache.ts) and none in the changed files. .vue files are not covered by tsc.
- **`scripts/check-phase33.sh`:** self-test, go, plugins, docs, marker, additive (5 packages, attach +1 line), admin, docker, consumers and compat all PASS. compat printed the known refusals for sm-feedback-plugin, sm-presentations-plugin and sm-quizzes-plugin (skipped, they fail at v0.6.0 too). `--layers` was skipped (known broken, deferred-items 33-08), and its steps were run by hand for vue-journal-layer as listed above.
## User Setup Required
None for the defaults. Optional: install `libwebp-dev` on servers for about five times faster encoding. For a static or musl deploy of an app with Journal, build with `-tags nodynamic`. Run `journal:image-variants` once per site to backfill existing featured images.
## Next Phase Readiness
- **33-12 / 33-13:** E10, Auto-links and the resolve keys are in place. vue-seo-layer already consumes `autolinks` and `autolink_max` (33-09).
- **33-14:**
- Bump the sm-seo-plugin, sm-journal-plugin and vue-journal-layer pins in the apps. sm-journal-plugin then needs the framework at v0.6.1 (`attach/webp`).
- Decide each app's build on the glibc linking (`-tags nodynamic` or not).
- Name the new migrations (SEO 202610280007, Journal 202610280002), the `journal:image-variants` backfill and the gen2brain/webp dependency in the release notes.
## Self-Check: PASSED
- Files: every created file listed in `key-files.created` exists (checked with `test -f` in each repository).
- Commits: `6203aec`, `ab4e834`, `49cc241` (sm-seo-plugin); `1bb2ecf`, `5c07224` (summercms.go); `4f5bd19` (sm-journal-plugin); `1080a3f` (vue-journal-layer). Each was found with `git cat-file -e`.

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,412 @@
---
phase: 33-seo-output-audit-and-content
plan: 12
subsystem: apps
tags: [seo, sitemap, nuxt, vue-seo-layer, demo, summercms-io, bm, noindex, patches]
requires:
- phase: 33-03..33-06
provides: sitemap index and files, SEO plugin page registry, site answer, JSON-LD graph, breadcrumbs
- phase: 33-07..33-09
provides: vue-seo-layer v0.2.0 (resolve, root files, build guard, noindex routes, route report, components) and the journal, menu and faq layers on it
- phase: 33-11
provides: WebP variants (purego linking note)
provides:
- "33-12-sm-demo-app.patch: Journal (tag_pages false), sitemap (site_url) and SEO (route_report_token) config, nginx without sitemap locations, env templates, DEPLOY.md SEO steps, deploy/seo-pages.csv, TestSEOOutput, scripts/smoke-seo.sh, scripts/smoke.sh and check-deploy.sh on the new format"
- "33-12-vue-demo-app.patch: seo.noindexRoutes ['/p/**', '/presentations/**'], page meta instead of hand-written robots, SeoSitemapLink in the footer, @nuxtjs/seo keys removed, SSR resolve through /api/__seo__"
- "33-12-vue-summercmsio-app.patch: changelog on seo: { cms: false, htmlSitemap: false } and 12 new expectation lines"
- "33-12-sm-summercmsio-app.patch: the empty note (no change beyond the pin bumps)"
- "33-12-local-wiring.md: worktrees, go.work, pnpm overrides, run variables, checks and cleanup for 33-13 and 33-14"
- "sm-bm-app bd7ee84: robots.txt Disallow, robots meta, X-Robots-Tag in nginx, scripts/check-noindex.sh (D-72)"
affects: [33-13, 33-14]
actuals:
tokens: 30000
tasks: 3
commits: 2
plan_head_before: 617e15d
plan_head_after: 298c81d701c697e77cc7b4b5e8639f07f87a98e2
tech-stack:
added: []
patterns:
- "Release patches from scratch worktrees: git add -N for new files, git diff --binary over an explicit path list, so the wiring (go.work, plugins.gen.go, pnpm overrides and lockfile) never enters a patch"
- "Scratch Nuxt wiring with pnpm 12 overrides in pnpm-workspace.yaml (link: to the sibling layers, applied at every depth)"
- "An SEO smoke run against a site: CMS head checks, a sitemap crawl through the site with duplicate and noindex detection, a redirect and a 404 created and removed through the SEO API"
key-files:
created:
- .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/scripts/check-noindex.sh
modified:
- ../sm-bm-app/frontend/index.html
- ../sm-bm-app/deploy/nginx/bm.jakubzych.com.conf
- ../sm-bm-app/DEPLOY.md
- ../sm-bm-app/README.md
- .planning/phases/33-seo-output-audit-and-content/deferred-items.md
- .planning/WINDOWS.md
key-decisions:
- "The demo keeps its cookie-based Polish (D-13). Moving to /pl/ prefixes is a Rule 4 change and goes to 33-14 as a decision; smoke-seo.sh reports the /pl/ 404s as WARN lines (SMOKE_SEO_STRICT=1 fails on them)"
- "Demo sitemaps go through Nuxt: nginx drops = /sitemap.xml and = /sitemap.xsl and gets no /_sitemap or /_seo location, and check-deploy.sh refuses one. robots.txt stays on the Go port"
- "The demo resolves client-side navigations through the Nuxt server (public.seo.apiBase /api/__seo__), so /_seo stays unrouted"
- "Registry page titles: deploy/seo-pages.csv restores 'SummerCMS Demo' (/) and 'FAQ' (/faq), which the registry would otherwise derive as 'Home' and 'Faq'"
- "sm-bm-app's check-noindex.sh builds the frontend into a temp outDir, so the committed build in the jz.bm plugin submodule is not touched"
requirements-completed: [SC-1, SC-2, D-72, D-70, D-23, D-97, D-33, D-22, D-30, D-34]
coverage:
- id: D1
description: "The demo stack on local links serves a Journal post with the CMS title, one canonical, one JSON-LD @graph, hreflang and breadcrumbs, /sitemap.xml as an index whose files answer through the layer, a 301 from an SEO plugin redirect, noindex on /p/** and the auth pages, the reported routes in the registry and a 404 in the 404 log"
requirement: SC-1
verification:
- kind: e2e
ref: "SMOKE_SEO_API=http://127.0.0.1:8120 bash scripts/smoke-seo.sh http://127.0.0.1:3020 (smoke-seo: ok, 29 ok lines, 9 WARN)"
status: pass
human_judgment: false
- id: D2
description: "The demo build passes the D-70 guard with seo.noindexRoutes and fails without the line"
requirement: D-70
verification:
- kind: automated_ui
ref: "pnpm exec nuxt build in the vue-demo-app worktree: exit 0 with the line, exit 1 '[vue-seo-layer] indexable routes must render on the server (D-70): /p/**, /presentations/**' without it"
status: pass
human_judgment: false
- id: D3
description: "TestSEOOutput (Docker): sitemap index and file on the public origin, no tag address and /blog/tag/releases not_found with tag_pages false, site answer, post resolve with @graph and breadcrumbs, route report with route_report_token"
requirement: SC-2
verification:
- kind: integration
ref: "go test -tags summercms_mcp -run TestSEOOutput -count=1 . (pass; fails without config/golem15.journal.yaml)"
status: pass
human_judgment: false
- id: D4
description: "summercms.io's changelog on seo.cms false keeps its titles, one canonical, one graph and makes no resolve call; landing generate, unit tests and marketplace smoke pass"
requirement: D-97
verification:
- kind: e2e
ref: "pnpm run build:changelog && pnpm run test:changelog (smoke-ssr: ok (89 checks)); pnpm run generate; pnpm test (82 pass, 0 fail); pnpm run test:marketplace (30 checks)"
status: pass
human_judgment: false
- id: D5
description: "sm-bm-app has no indexable page: robots.txt Disallow, robots meta, X-Robots-Tag in both server blocks and the location with its own add_header"
requirement: D-72
verification:
- kind: unit
ref: "bash ../sm-bm-app/scripts/check-noindex.sh (check-noindex: ok; three negative runs fail)"
status: pass
human_judgment: false
- id: D6
description: "Patches apply cleanly to the live HEADs"
verification:
- kind: other
ref: "git apply --check of all four patches (sm-summercmsio-app with --allow-empty)"
status: pass
human_judgment: false
- id: D7
description: "The demo's noindex set covers only browser-only or private routes (D-70 values prohibition); subscriptions is marked signed-in per P33-48 although its plan list is visible to visitors"
requirement: D-70
verification: []
human_judgment: true
rationale: "Whether /subscriptions (plans shown to visitors, subscribing needs an account) counts as a signed-in page is a product judgement for Jakub at 33-14's release checkpoint"
duration: 50min
completed: 2026-11-04
status: complete
---
# Phase 33 Plan 12: Demo, summercms.io and bm on the Phase 33 stack Summary
**The demo runs end to end on local links: CMS head, one graph, breadcrumbs, the sitemap index through vue-seo-layer, a CMS redirect enforced by Nuxt, D-70/D-23 noindex. Release patches for the demo and summercms.io apply to the current HEADs, and bm is noindexed by file, meta and header. The run also found that the demo's cookie-based Polish leaves 8 of 23 sitemap addresses (all `/pl/...`) answering 404.**
## Performance
- **Duration:** about 50 min (16:25Z to 17:15Z)
- **Tasks:** 3 of 3
- **Files:**
- four patches and a wiring note in the phase directory;
- six files in sm-bm-app;
- deferred-items.md and WINDOWS.md.
## Ports and wiring
Ports:
- Go `127.0.0.1:8120`. dev.sh uses 8097; the other port avoids a clash with a running dev stack.
- Nuxt `127.0.0.1:3020`.
- Scratch Postgres `127.0.0.1:55561` (container `sm-demo-pg-scratch33`, now removed).
- 33-13's Nuxt `3016` and its two worktrees were not touched.
33-12-local-wiring.md has every command.
The plan's Task 1 verify line uses the default `SMOKE_SEO_API=http://127.0.0.1:8097`. This run needs `SMOKE_SEO_API=http://127.0.0.1:8120`.
## Accomplishments
### Task 1: tracer, demo end to end on local links
The tracer gate passed: `<verify>` was re-run after the fixes, and it ended `smoke-seo: ok`.
**Setup:**
- worktrees under `../../scratch-33`;
- a scratch go.work with sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin and the framework replace;
- pnpm 12 overrides for the four layers;
- `summer build`;
- a fresh Postgres, then `migrate`, `admin:create` and `demo:seed` (posts with categories and Polish translations);
- `SUMMER_ENV=production` with local overrides, so the staging lock does not mask the noindex checks;
- `sitemap:rebuild`;
- the `seo-pages.csv` import.
**Final smoke** (`$S/smoke-seo-final.txt`):
```
ok /blog/welcome-to-summercms answers 200
ok /blog/welcome-to-summercms has one ld+json script
ok /blog/welcome-to-summercms ld+json is one @graph
ok /blog/welcome-to-summercms has one canonical
ok /blog/welcome-to-summercms has 3 hreflang links
ok /blog/welcome-to-summercms has nav.seo-breadcrumbs (2 links)
ok /blog/welcome-to-summercms title is the CMS title (Welcome to SummerCMS)
ok /blog/welcome-to-summercms X-Robots-Tag is the CMS's (index, follow)
ok alternate /blog/welcome-to-summercms answers 200
WARN alternate /pl/blog/welcome-to-summercms answers 404
ok /sitemap.xml is a sitemap index (application/xml)
ok /sitemap-authors-1.xml answers a urlset through the site
WARN sitemap address /pl/blog/author/summercms-team answers 404
ok /sitemap-categories-1.xml answers a urlset through the site
WARN sitemap address /pl/blog/category/uncategorized answers 404 (and news, guides)
ok /sitemap-pages-1.xml answers a urlset through the site
ok /sitemap-posts-1.xml answers a urlset through the site
WARN sitemap address /pl/blog/welcome-to-summercms answers 404 (and the other three posts)
ok 23 sitemap addresses fetched, 8 not 200
ok no address is listed twice in the sitemaps
ok /robots.txt names the sitemap index
ok /p/smoke-seo (200) sends X-Robots-Tag: noindex, nofollow
ok /presentations/new (200) sends X-Robots-Tag: noindex, nofollow
ok /login (200) sends X-Robots-Tag: noindex, nofollow
ok /register (200) sends X-Robots-Tag: noindex, nofollow
ok /account/security (200) sends X-Robots-Tag: noindex, nofollow
ok /login has the robots meta noindex, nofollow
ok / (200) links the HTML sitemap in the footer
ok /sitemap lists /blog/welcome-to-summercms
ok /smoke-seo-old-1793811624-4025853 answers 301 to /blog/welcome-to-summercms (SEO plugin redirect 4)
ok the registry lists the reported routes: / /blog /chat /contact /faq /forum /quizzes
ok /smoke-seo-missing-1793811625-4025853 answers 404
ok /smoke-seo-missing-1793811625-4025853 reached the 404 log (entry 12)
smoke-seo: ok
```
**Negative run** on rome-like sitemap data (the migrated additional addresses `/` and `/subscriptions`), which the smoke must catch:
```
FAIL sitemap address /subscriptions answers X-Robots-Tag: noindex, nofollow
FAIL addresses listed more than once in the sitemaps: /
```
### Task 2: release patches
**33-12-sm-demo-app.patch** (13 files):
- config:
- `config/golem15.journal.yaml` (new, `tag_pages: false`);
- `config/golem15.sitemap.yaml` (new, `site_url: https://demo.summercms.io`);
- `config/golem15.seo.yaml` (`route_report_token: ""` from the environment).
- deploy files:
- `deploy/env.example` and `deploy/site.env.example`: SEO tokens and the `NUXT_SEO_*` bases; `NUXT_PUBLIC_SITE_URL` and `NUXT_PUBLIC_SITEMAP_URL` dropped, since nothing reads them;
- `deploy/nginx/demo.summercms.io.conf`: `= /sitemap.xml` and `= /sitemap.xsl` dropped;
- `deploy/seo-pages.csv` (new).
- DEPLOY.md:
- the overview, the one-time setup with the SEO tokens, and the rewritten SEO section;
- the "Before" and "After the first release with Phase 33" steps (the token append refuses when a key exists and was dry-run twice; the additional-address cleanup and the CSV import were tested on the scratch backend);
- the smoke-seo.sh usage.
- README.md.
- `scripts/check-deploy.sh`: refuses `/_seo`, `/_sitemap` and `/sitemap*` locations.
- `scripts/smoke.sh`:
- runs `sitemap:rebuild` and checks the index on Go and, through the site, `/sitemap.xml`, `sitemap-posts-1.xml` and `/robots.txt`;
- starts the site with `NUXT_SEO_API_BASE`;
- replaces the deck check that relied on nuxt-site-config's `window.__NUXT_SITE_CONFIG__` with the empty-app-root and `X-Robots-Tag` check.
- `scripts/smoke-seo.sh` (new, 0755).
- `seo_output_test.go` (new).
**33-12-vue-demo-app.patch** (18 files):
- nuxt.config.ts:
- `site`, `robots`, `linkChecker` and `public.sitemapUrl` removed;
- `seo: { noindexRoutes: ['/p/**', '/presentations/**'] }`;
- `runtimeConfig.seo.apiBase` and `public.seo.apiBase: '/api/__seo__'`.
- Page meta, per P33-48:
- `seo: { robots: 'noindex, nofollow' }` on login, register, connect and link-admin;
- `requiresAuth: true` on account/security, subscriptions, friends, call/[conversation] and forum/t/[id];
- `seo: { robots: 'noindex' }` on quizzes/[slug] (login required) and roadmap (a redirect only);
- the robots value dropped from the three /presentations pages (noindexRoutes covers them).
- The footer's XML sitemap link is replaced by `<SeoSitemapLink />`, and the unused `footer.sitemap` labels are dropped.
- The patch adds no `useSeoMeta(` line with a robots value.
**33-12-vue-summercmsio-app.patch:**
- `changelog/nuxt.config.ts`: `seo: { cms: false, htmlSitemap: false }`;
- `changelog/testing/expect.txt`: 12 lines, covering title, canonical ×1, ld+json ×1, h1 ×1, no breadcrumbs, 404 for /sitemap and /mapa-strony, and `resolves * 0`.
The card and heading markup needed no expectation change: the app overrides PostCard, PostList and IndexHeader, so the layer's new card markup does not reach it.
**33-12-sm-summercmsio-app.patch:** the single note line. It needs `git apply --check --allow-empty` (see Deviations).
**Results:**
- demo:
- build passes, and fails without the noindexRoutes line;
- `go vet ./...`, `go test -short ./...` and `go test -tags summercms_mcp -count=1 ./...` (Docker) pass;
- `scripts/smoke.sh bin/demo` ends `smoke: ok`;
- `scripts/check-deploy.sh` ends `check-deploy: ok`;
- `pnpm test` gives 225/225.
- summercms.io:
- `test:changelog` gives 89 checks;
- `generate` passes;
- `pnpm test` gives 82 pass, 0 fail;
- `test:marketplace` gives 30 checks;
- Go `go test ./...` passes on the wiring.
**Cleanup:** the four worktrees were removed with `git worktree remove --force` after the patch commits. The demo worktree was recreated once for the TestSEOOutput fix and removed again. `git worktree list` shows only the main checkouts. The scratch Postgres and both servers are stopped. Ports 8120, 3020 and 55561 are free.
### Task 3: sm-bm-app (D-72)
- **Changes:**
- `frontend/public/robots.txt`;
- the robots meta in `frontend/index.html`;
- `add_header X-Robots-Tag "noindex, nofollow" always;` in both server blocks and in `location = /prezentacje/AW_tematy-prezentacji.html`;
- `scripts/check-noindex.sh`;
- DEPLOY.md, "Search engines (noindex)";
- one README line.
- **Check:** `check-noindex: ok`. Removing the header from the port-80 server, the header from the location, or the meta each fails with its own line.
- **Commit:** bd7ee84 on master, local, not pushed.
- **sm-bm-app `.planning`:** there were no other-session `.planning` changes to preserve; `git status --short .planning` was empty before and after.
## Task Commits
| Repo | Commit | Message |
|------|--------|---------|
| summercms.go | e1d5110 | docs(33-12): app patches for the release |
| summercms.go | 298c81d | docs(33-12): demo patch tags a post in TestSEOOutput |
| sm-bm-app | bd7ee84 | feat: keep the app out of search engines (noindex, Disallow) |
## Decisions Made
See `key-decisions` in the frontmatter. The main one: the demo's /pl/ problem is reported, not fixed (Rule 4).
## Deviations from Plan
### Auto-fixed and plan-shape deviations
**1. [Rule 1 - Bug] TestSEOOutput's tag check was vacuous**
- **Found during:** Task 2, after the first patch commit.
- **Issue:** demo:seed creates no tag, so the test passed without `config/golem15.journal.yaml` too.
- **Fix:** the test tags the seeded post and requires `/blog/tag/releases` to resolve `not_found`. Without the config the test fails: `resolves "ok", want not_found`.
- **Commit:** 298c81d. The patch was regenerated and re-checked.
**2. [Rule 1 - Bug] The live run never served `config/golem15.journal.yaml`**
- **Found during:** cleanup.
- **Issue:** the first manually started Go process kept port 8120, because its pid file recorded the subshell's pid. The later restarts could not bind the port. The local stack therefore served the config as it was at the first start, plus the env overrides.
- **Effect:** every smoke result stands, because none depends on the files added later. `tag_pages: false` is proven by TestSEOOutput only.
**3. [Rule 3 - Blocking] The release smoke depended on nuxt-site-config**
- **Issue:** `scripts/smoke.sh` used `window.__NUXT_SITE_CONFIG__` to prove the deck page renders in the browser only. That module left with @nuxtjs/seo.
- **Fix:** replaced by the empty `#__nuxt` root and the declared `X-Robots-Tag`. The smoke also starts the site with `NUXT_SEO_API_BASE` and checks the new sitemap format.
**4. [Rule 2 - Missing] Registry pages lose their titles**
- **Issue:** the route report creates registry pages named from their path ("Home", "Faq").
- **Fix:** `deploy/seo-pages.csv` and a DEPLOY import step bring back "SummerCMS Demo" and "FAQ", as P33-50 does for 33-13's site. Tested: the import applied with 2 changed and 0 refused, and the titles showed at once.
**5. [Rule 2 - Missing] Migrated sitemap addresses on rome**
- **Issue:** on rome the old sitemap definition becomes additional addresses (`/`, `/blog`, `/faq`, `/subscriptions`). That gives duplicates and a noindex address in the sitemap.
- **Fix:** DEPLOY.md's "After the first release" removes them through `PUT /_sitemap/api/v1/settings` (tested on the scratch backend). smoke-seo.sh now fails on duplicates and on noindex addresses.
**6. [Rule 2 - Missing] HTML sitemap pages on summercms.io's changelog**
- **Issue:** the layer registered `/sitemap` and `/mapa-strony` pages, which only answer 404 there.
- **Fix:** `htmlSitemap: false` next to `cms: false`. The expectations pin both 404s.
**7. Plan assumption: the empty patch**
- **Plan:** "git apply treats it as an empty patch".
- **Actual:** plain `git apply --check` answers `error: No valid patches in input (allow with "--allow-empty")`, exit 128. With `--allow-empty` it exits 0.
- **For 33-14:** use the flag or skip the file.
**8. Scope additions to the demo patch**
- **Added beyond the plan's list:** check-deploy.sh, smoke.sh, README.md, `deploy/env.example`, `deploy/site.env.example`, `deploy/seo-pages.csv` and `config/golem15.journal.yaml`.
- **Why:** each was needed for the new format or for correct output.
**9. P33-48 followed for `/subscriptions`**
- **What:** the page gets `requiresAuth: true`, as P33-48 says. The plan list is visible to visitors.
- **Open:** whether that is "signed-in" is listed as D7 (human judgement) for 33-14's checkpoint.
### Auth gates
None.
## Issues Encountered
**The demo's cookie-based Polish** (deferred-items.md, WINDOWS #34). The demo serves Polish from a cookie at one address, but the CMS names `/pl/…` addresses, which affects three outputs:
- hreflang;
- the sitemaps: 8 of 23 addresses, all 404;
- the Polish visitor's head: English CMS title over a Polish body.
Fixing it means moving the demo to `prefix_except_default`, which reverses D-13. **33-14 or Jakub must decide this before the demo patches are applied.**
Other findings, all in deferred-items.md:
- **Sitemap duplicates** (WINDOWS #35): the sitemap plugin does not de-duplicate additional addresses against content records.
- **Noindex additional addresses:** an additional address the site serves noindex stays listed.
- **Tag pattern in the site answer:** the SEO site answer lists `journal-tag` with `tag_pages: false` (a drift warning at every start).
- **Duplicate type export:** vue-journal-layer warns `Duplicated imports "JournalImageVariant"` on every build.
**Local quirks** (in the wiring note):
- the admin login throttle (5 a minute);
- `cp` is interactive in this shell (`/bin/cp -f`);
- `go build ./...` in sm-summercmsio-app drops a binary in the repository root (that is the untracked `sm-summercmsio-app` file in the live checkout);
- the secret-read guard blocks `.env` files, so the run variables lived in `$S/demo-run.vars` and were deleted at cleanup.
## For 33-14
**Pins:**
- sm-demo-app:
- go.mod requires and submodule pointers for sm-journal-plugin, sm-sitemap-plugin and sm-seo-plugin (all three move together, because the SEO plugin needs the sitemap `contract`), plus summercms v0.6.1;
- then `summer build` (plugins.gen.go), then apply 33-12-sm-demo-app.patch.
- vue-demo-app: pins vue-journal-layer v0.8.0, vue-menu-layer v0.5.0 and vue-faq-layer v0.5.0. vue-seo-layer comes in through them; add a direct pin if the layers' `link:` dependency becomes a git pin. Then apply 33-12-vue-demo-app.patch.
- sm-summercmsio-app: summercms v0.6.1 and sm-journal-plugin (submodule and require). Skip the empty patch, or apply it with `--allow-empty`.
- vue-summercmsio-app: vue-journal-layer v0.8.0, then apply 33-12-vue-summercmsio-app.patch. The changelog still gets @nuxtjs/seo from the landing ('..'), so the canonical and graph stay single.
- sm-bm-app:
- robots.txt and the meta reach rome only after `npm --prefix frontend run build` is committed in the jz.bm plugin repository (`jakub/sm-bm-plugin`, `plugins/jz/bm/public`), pushed, and the pointer bumped. 33-12 committed only in sm-bm-app.
- The nginx header is inserted into the live vhost by hand (DEPLOY.md, "Search engines (noindex)").
**Linking decision:**
- **Demo:** `scripts/build.sh` builds the release with `CGO_ENABLED=0` and no `nodynamic`, which gives a dynamically linked binary (checked: "dynamically linked"; with `-tags summercms_mcp,nodynamic` "statically linked").
- **summercms.io:** `scripts/build.sh` uses `CGO_ENABLED=0`, and the scratch build is "dynamically linked".
- **grzybyfunkcjonalne:** `CGO_ENABLED=0` as well (not built here).
- **DEPLOY.md:** both the demo's and summercms.io's say "(linux/amd64, static)". Add `nodynamic` or change that line.
**Rome steps:**
- the "Before" and "After the first release with Phase 33" blocks in the demo's DEPLOY.md: SEO tokens, NUXT_SEO bases, the vhost without the sitemap locations, clearing the additional addresses, `sitemap:rebuild`, the seo-pages.csv import;
- `journal:image-variants` once per site with Journal (33-11).
**Release-note items:**
- **Demo:** sitemap index and per-type files served by the site; `/sitemap.xsl` gone; noindex on decks and auth/signed-in pages; HTML sitemap link in the footer; page titles from the SEO plugin.
- **summercms.io:** changelog on the new journal layer with the SEO layer off.
- **bm:** noindex everywhere.
## Known Stubs
None. Two items are open (not stubs) and are in deferred-items.md and WINDOWS: the demo's `/pl/` WARN lines and the sitemap duplicates.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: credential-handling | sm-demo-app scripts/smoke-seo.sh | Logs in as an admin to create and delete a redirect and a 404 log entry. The password is read from a file or the environment and passed through stdin and a temp file under mktemp, never argv; the token goes through `-H @file`; it logs out at exit. The default password is scripts/dev.sh's local admin. |
| threat_flag: secret-file-edit | sm-demo-app DEPLOY.md | The "Before the first release" block appends tokens to .env and site.env on rome and deletes two unused keys. It prints no value and refuses when a key exists. |
## Self-Check: PASSED
- FOUND: 33-12-sm-demo-app.patch, 33-12-vue-demo-app.patch, 33-12-sm-summercmsio-app.patch, 33-12-vue-summercmsio-app.patch, 33-12-local-wiring.md (phase directory)
- FOUND: ../sm-bm-app/frontend/public/robots.txt, ../sm-bm-app/scripts/check-noindex.sh
- FOUND commits: e1d5110, 298c81d (summercms.go), bd7ee84 (sm-bm-app)
- `git apply --check` passes for all four patches against the live HEADs (the empty one with `--allow-empty`)
- `bash ../sm-bm-app/scripts/check-noindex.sh` gives `check-noindex: ok`
- `grep -c noindexRoutes 33-12-vue-demo-app.patch` gives 1
- No scratch worktree of this plan remains, and none of this plan's processes are listening

View File

@@ -0,0 +1,202 @@
# 33-12 local wiring (P33-46)
How plan 33-12 ran the demo and summercms.io against the unreleased Phase 33 repositories, without pushing or tagging anything. 33-13 and 33-14 repeat these steps. Nothing here is committed to an application repository: the wiring (go.work, pnpm overrides, lockfiles, `plugins.gen.go`) stays in the scratch worktrees, and the patches carry only the changes that stay after the release (P33-47).
## Paths and ports
| What | Value |
|------|-------|
| Scratch directory | `/media/nvme/dev/golem15/summercms.io/scratch-33` (`$S`; outside every repository) |
| Sibling root | `/media/nvme/dev/golem15/summercms.io/summercms` (`$R`) |
| Demo Go binary | `127.0.0.1:8120` (dev.sh uses 8097; 8120 avoids a clash with a running dev stack) |
| Demo Nuxt server | `127.0.0.1:3020` (the dev.sh port; app.url and the site URLs point at it) |
| Scratch Postgres | `127.0.0.1:55561`, container `sm-demo-pg-scratch33`, postgres:17-alpine (sm-demo-pg on 55540 untouched) |
| 33-13's site | Nuxt `127.0.0.1:3016`, worktrees `$S/sm-grzybyfunkcjonalne-app` and `$S/vue-grzybyfunkcjonalne-app` (not touched by 33-12) |
Repository heads used: sm-demo-app da481c9, vue-demo-app 9a12701, sm-summercmsio-app ca06049, vue-summercmsio-app f36cbf1; sm-seo-plugin 49cc241, sm-sitemap-plugin 9e5c393, sm-journal-plugin 4f5bd19, vue-seo-layer 7af91fc, vue-journal-layer 1080a3f, vue-menu-layer 8b5da67, vue-faq-layer f7ec4fe, summercms.go at the 33-11 commits.
## Worktrees
```sh
S=/media/nvme/dev/golem15/summercms.io/scratch-33
R=/media/nvme/dev/golem15/summercms.io/summercms
mkdir -p $S
git -C $R/sm-demo-app worktree add --detach $S/sm-demo-app HEAD
git -C $R/sm-demo-app/vue-demo-app worktree add --detach $S/vue-demo-app HEAD
git -C $R/sm-summercmsio-app worktree add --detach $S/sm-summercmsio-app HEAD
git -C $R/sm-summercmsio-app/vue-summercmsio-app worktree add --detach $S/vue-summercmsio-app HEAD
```
A worktree's submodules are empty. The Go wiring points at the live checkouts' plugin submodules, and the Nuxt app lives in its own worktree. Tests that read the site from inside the Go repository need the empty directory replaced by a symlink for the run, and then restored before the patch is made:
```sh
rmdir $S/sm-demo-app/vue-demo-app && ln -s $S/vue-demo-app $S/sm-demo-app/vue-demo-app # scripts/smoke.sh
/bin/rm -f $S/sm-demo-app/vue-demo-app && mkdir $S/sm-demo-app/vue-demo-app # before git diff
```
The same applies to `$S/sm-summercmsio-app/vue-summercmsio-app` for `TestTerminalScript`.
## Go: sm-demo-app
`$S/sm-demo-app/go.work` replaces the committed one. It uses the live plugin submodules, except the three Phase 33 plugins, which come from the sibling checkouts. The SEO plugin needs the sitemap plugin's `contract` package, which the demo's pinned v0.1.2 submodule lacks.
```sh
cd $S/sm-demo-app
{
echo "go 1.27.0"; echo; echo "toolchain go1.27.0"; echo; echo "use ("; echo " ."
for p in chat chatbots chatchannels chatvideo faq feedback forum golem herald menu mfa moderation oauthserver pgstripe presentations quizzes sociallogin translate userfriends; do echo " $R/sm-demo-app/plugins/golem15/$p"; done
echo " $R/sm-journal-plugin"; echo " $R/sm-seo-plugin"; echo " $R/sm-sitemap-plugin"; echo ")"; echo
echo "replace git.golem15.com/golem15/summercms => $R/summercms.go"; echo
echo "replace ("
echo " git.golem15.com/golem15/sm-paymentgateway-plugin => $R/sm-demo-app/plugins/golem15/paymentgateway"
echo " git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin => $R/sm-demo-app/plugins/golem15/paymentgatewaysubscriptions"
echo " git.golem15.com/golem15/sm-user-plugin => $R/sm-demo-app/plugins/golem15/user"
echo ")"
} > go.work
cp $R/sm-demo-app/go.work.sum .
(cd $R/summercms.go && go build -o $S/summer ./cmd/summer) # the CLI from the framework HEAD
$S/summer build # bin/demo (tags summercms_mcp); regenerates plugins.gen.go
```
`summer build` rewrites `plugins.gen.go` with the sibling versions (`v0.3.1-11-g4f5bd19` and the like). That file is wiring: 33-14 regenerates it on the real pins.
## Go: sm-summercmsio-app
Its `go.mod` replaces the plugins with `./plugins/golem15/*`, which are empty in a worktree. `go.work` replaces them at every version and uses only the app. Listing the plugins under `use` as well fails with "workspace module … is replaced at all versions".
```sh
cd $S/sm-summercmsio-app
cat > go.work <<EOF
go 1.27.0
toolchain go1.27.0
use .
replace git.golem15.com/golem15/summercms => $R/summercms.go
replace (
git.golem15.com/golem15/sm-journal-plugin => $R/sm-journal-plugin
git.golem15.com/golem15/sm-summercmsio-plugin => $R/sm-summercmsio-app/plugins/golem15/summercms
git.golem15.com/golem15/sm-translate-plugin => $R/sm-summercmsio-app/plugins/golem15/translate
git.golem15.com/golem15/sm-user-plugin => $R/sm-summercmsio-app/plugins/golem15/user
)
EOF
cp $R/sm-summercmsio-app/go.work.sum .
$S/summer build
```
Don't run `go build ./...` in the module root: it writes the binary `sm-summercmsio-app` there. The untracked `sm-summercmsio-app` file in the live checkout is such a binary.
## Nuxt: pnpm overrides
The same four lines go in each Nuxt worktree's `pnpm-workspace.yaml`. pnpm 12 reads `overrides:` there, and they apply at every depth, so the journal, menu and faq layers' own `link:../vue-seo-layer` resolves to the same checkout:
```sh
for d in $S/vue-demo-app $S/vue-summercmsio-app; do cat >> $d/pnpm-workspace.yaml <<EOF
overrides:
vue-seo-layer: link:$R/vue-seo-layer
vue-journal-layer: link:$R/vue-journal-layer
vue-menu-layer: link:$R/vue-menu-layer
vue-faq-layer: link:$R/vue-faq-layer
EOF
done
(cd $S/vue-demo-app && pnpm install && pnpm exec nuxt build)
export NUXT_PUBLIC_SUMMERCMS_VERSION=v0.6.1 # the summercms.io worktree has no ../go.mod
(cd $S/vue-summercmsio-app && pnpm install && pnpm run build:changelog && pnpm run test:changelog && pnpm run generate)
```
`pnpm install` rewrites `pnpm-lock.yaml` (wiring, left out of the patches). The build warns `Duplicated imports "JournalImageVariant"` (vue-journal-layer exports the type from two utils; harmless, deferred-items.md).
## The demo stack
The run variables live in `$S/demo-run.vars` (mode 0600, outside every repository). The tokens are generated per run and never printed (T-33-56):
```sh
rnd() { head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
(umask 077; cat > $S/demo-run.vars <<EOF
SUMMER_ENV=production
SUMMER_DATABASE__DSN=postgres://summercms_demo:summercms_demo@127.0.0.1:55561/summercms_demo?sslmode=disable
SUMMER_APP__KEY=$(head -c 32 /dev/urandom | base64)
SUMMER_ADMIN__JWT__SECRET=$(head -c 32 /dev/urandom | base64)
SUMMER_GOLEM15__USER__JWT__SECRET=$(head -c 32 /dev/urandom | base64)
SUMMER_GOLEM15__DEMO__VISITOR__EMAIL=visitor@example.test
SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD=$(rnd | cut -c1-24)
SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=$(rnd)
SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=$(rnd)
SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3020/api/__site__/cache/purge
SUMMER_SITE_CACHE__PURGE_TOKEN=$(rnd)
SUMMER_APP__URL=http://127.0.0.1:3020
SUMMER_GOLEM15__SEO__SITE_URL=http://127.0.0.1:3020
SUMMER_GOLEM15__SITEMAP__SITE_URL=http://127.0.0.1:3020
SUMMER_MCP__RESOURCE=http://127.0.0.1:3020/mcp
SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE=http://127.0.0.1:3020/mcp
SUMMER_REALTIME__DRIVER=null
SUMMER_GOLEM15__USER__REGISTRATION__USE_REGISTER_THROTTLE=false
EOF
)
```
`SUMMER_ENV=production` matters. Outside production, the SEO plugin answers every page `noindex, nofollow` (staging lock), and the noindex checks would prove nothing. The local overrides replace the production overlay's demo.summercms.io addresses. A file named `.env` cannot be used from an agent session, because the secret-read guard blocks any command that touches it.
```sh
docker run -d --name sm-demo-pg-scratch33 -e POSTGRES_DB=summercms_demo -e POSTGRES_USER=summercms_demo \
-e POSTGRES_PASSWORD=summercms_demo -p 127.0.0.1:55561:5432 postgres:17-alpine
cd $S/sm-demo-app && mkdir -p logs storage/app/uploads/public
set -a; . $S/demo-run.vars; set +a
./bin/demo migrate
printf '%s\n' 'AdminDemo123!' | ./bin/demo admin:create --email admin@example.test --login admin --superuser
./bin/demo demo:seed # Journal posts with categories and Polish translations
bash $S/run-demo-go.sh # bin/demo serve --addr 127.0.0.1:8120 (pid in logs/go.pid)
bash $S/run-demo-nuxt.sh # node .output/server/index.mjs on 127.0.0.1:3020 (pid in $S/logs/demo-nuxt.pid)
./bin/demo sitemap:rebuild # after the Nuxt start, so the reported pages are listed
```
`run-demo-nuxt.sh` passes the Nuxt server, under `env -i`, every `NUXT_*_API_BASE` at `http://127.0.0.1:8120`, plus `NUXT_SEO_API_BASE=http://127.0.0.1:8120/_seo/api/v1`, `NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8120`, `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN` and `NUXT_CACHE_PURGE_TOKEN`, all from the vars file. `stop-demo.sh` stops both servers. The page titles of `deploy/seo-pages.csv` were imported with `POST /_seo/api/v1/import` (`{"dataset": "records", "csv": …}`), as DEPLOY.md's "After the first release with Phase 33" describes.
## Checks
```sh
cd $S/sm-demo-app
SMOKE_SEO_API=http://127.0.0.1:8120 bash scripts/smoke-seo.sh http://127.0.0.1:3020 # smoke-seo: ok
go vet ./... && go test -short ./...
go test -tags summercms_mcp -count=1 ./... # Docker (testcontainers)
go test -tags summercms_mcp -run 'TestSEOOutput|TestSEOTabMounted|TestBootAllPlugins' -count=1 -v .
bash scripts/smoke.sh bin/demo # with the vue-demo-app symlink
bash scripts/check-deploy.sh
cd $S/vue-demo-app && pnpm test
```
The admin login is throttled to 5 attempts a minute per address. The smoke logs in once, so two runs in the same minute after other logins answer 429; wait a minute.
The SEO smoke is also kept outside the worktrees as `$S/33-12-smoke-seo.sh`, for 33-13 to reuse.
## Patches
```sh
cd $S/sm-demo-app && git add -N config/golem15.journal.yaml config/golem15.sitemap.yaml deploy/seo-pages.csv scripts/smoke-seo.sh seo_output_test.go
git diff --binary -- DEPLOY.md README.md config/golem15.journal.yaml config/golem15.seo.yaml config/golem15.sitemap.yaml \
deploy/env.example deploy/nginx/demo.summercms.io.conf deploy/seo-pages.csv deploy/site.env.example \
scripts/check-deploy.sh scripts/smoke-seo.sh scripts/smoke.sh seo_output_test.go > 33-12-sm-demo-app.patch
cd $S/vue-demo-app && git diff --binary -- $(git diff --name-only | grep -v '^pnpm-') > 33-12-vue-demo-app.patch
cd $S/vue-summercmsio-app && git diff --binary -- changelog/nuxt.config.ts changelog/testing/expect.txt > 33-12-vue-summercmsio-app.patch
printf '# no change beyond the pin bumps in 33-14\n' > 33-12-sm-summercmsio-app.patch
git -C $R/sm-demo-app apply --check 33-12-sm-demo-app.patch
git -C $R/sm-demo-app/vue-demo-app apply --check 33-12-vue-demo-app.patch
git -C $R/sm-summercmsio-app/vue-summercmsio-app apply --check 33-12-vue-summercmsio-app.patch
git -C $R/sm-summercmsio-app apply --check --allow-empty 33-12-sm-summercmsio-app.patch # without --allow-empty: "No valid patches in input"
```
## Cleanup
```sh
bash $S/stop-demo.sh
docker rm -f sm-demo-pg-scratch33
/bin/rm -f $S/demo-run.vars
git -C $R/sm-demo-app worktree remove --force $S/sm-demo-app
git -C $R/sm-demo-app/vue-demo-app worktree remove --force $S/vue-demo-app
git -C $R/sm-summercmsio-app worktree remove --force $S/sm-summercmsio-app
git -C $R/sm-summercmsio-app/vue-summercmsio-app worktree remove --force $S/vue-summercmsio-app
```
`--force` is needed because the worktrees hold the wiring and build output. The patches are committed before the removal.

View File

@@ -0,0 +1,975 @@
diff --git a/DEPLOY.md b/DEPLOY.md
index 19b6300..f2f4d0b 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -23,11 +23,12 @@ internet ──> Cloudflare (proxied, like summercms.io) ──443──> nginx
│ /_paymentgatewaysubscriptions/ /_presentations/ /_quizzes/
│ /_feedback/ /_demo/ /webhook/stripe/ /storage/app/uploads/public/
│ /_chat/ /_chatchannels/ /_chatbots/ /_chatvideo/ /_forum/ /_userfriends/
- │ = /api/realtime/token = /sitemap.xml = /sitemap.xsl
+ │ = /api/realtime/token = /robots.txt
│ = /mcp (unbuffered) /.well-known/oauth-protected-resource*
│ = /.well-known/oauth-authorization-server /oauth/mcp/
└── everything else (GET/HEAD) ...... 127.0.0.1:3098 summercms-demo-site (Nuxt SSR on Node)
│ server-side API calls to 127.0.0.1:8098
+ │ (also /sitemap.xml and its files, through vue-seo-layer)
bin/demo ── PostgreSQL 15 (database summercms_demo, role summercms_demo) ── storage/ (uploads)
└─ jobs in-process (Stripe webhooks); site-cache purge POST to 127.0.0.1:3098 (loopback only)
└─ publishes the phone remote's messages to Centrifugo's HTTP API on 127.0.0.1:8010
@@ -138,11 +139,12 @@ DBPW=$(openssl rand -hex 24)
printf "CREATE ROLE summercms_demo LOGIN PASSWORD '%s';\n" "$DBPW" | sudo -u postgres psql -v ON_ERROR_STOP=1 -q
sudo -u postgres createdb -O summercms_demo -E UTF8 summercms_demo
-# .env and site.env, root:summercms-demo 0640. The purge token is shared
-# (site.env names it NUXT_CACHE_PURGE_TOKEN, never NUXT_SITE_*: see Secrets).
+# .env and site.env, root:summercms-demo 0640. The purge token and the two
+# SEO tokens are shared (site.env names the purge token NUXT_CACHE_PURGE_TOKEN,
+# never NUXT_SITE_*: see Secrets).
sudo install -o root -g summercms-demo -m 0640 /dev/null /srv/summercms-demo/.env
sudo install -o root -g summercms-demo -m 0640 /dev/null /srv/summercms-demo/site.env
-PT=$(openssl rand -hex 32)
+PT=$(openssl rand -hex 32) NT=$(openssl rand -hex 32) RT=$(openssl rand -hex 32)
{
printf 'SUMMER_DATABASE__DSN=postgres://summercms_demo:%s@127.0.0.1:5432/summercms_demo?sslmode=disable\n' "$DBPW"
printf 'SUMMER_APP__KEY=%s\n' "$(openssl rand -base64 32)"
@@ -152,6 +154,8 @@ PT=$(openssl rand -hex 32)
printf 'SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD=%s\n' "$(openssl rand -hex 12)"
printf 'SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3098/api/__site__/cache/purge\n'
printf 'SUMMER_SITE_CACHE__PURGE_TOKEN=%s\n' "$PT"
+ printf 'SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=%s\n' "$NT"
+ printf 'SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=%s\n' "$RT"
} | sudo tee /srv/summercms-demo/.env >/dev/null
{
grep -v '^#' <<'EOF'
@@ -160,12 +164,14 @@ NUXT_MENU_API_BASE=http://127.0.0.1:8098/_menu/api/v1
NUXT_FAQ_API_BASE=http://127.0.0.1:8098/_faq/api/v1
NUXT_PAYMENTGATEWAY_API_BASE=http://127.0.0.1:8098/_paymentgateway/api/v1
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE=http://127.0.0.1:8098/_paymentgatewaysubscriptions/api/v1
-NUXT_PUBLIC_SITE_URL=https://demo.summercms.io
-NUXT_PUBLIC_SITEMAP_URL=/sitemap.xml
+NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1
+NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098
EOF
printf 'NUXT_CACHE_PURGE_TOKEN=%s\n' "$PT"
+ printf 'NUXT_SEO_NOTFOUND_TOKEN=%s\n' "$NT"
+ printf 'NUXT_SEO_ROUTE_REPORT_TOKEN=%s\n' "$RT"
} | sudo tee /srv/summercms-demo/site.env >/dev/null
-unset DBPW PT
+unset DBPW PT NT RT
```
The visitor email comes from the build machine's secrets file, piped over SSH (never on a command
@@ -662,8 +668,8 @@ the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (
`golem15.seo.manage`). `config/golem15.seo.yaml` sets `site_url` to `https://demo.summercms.io`.
- **robots.txt moves to Go.** nginx sends `= /robots.txt` to the Go port (`deploy/nginx/demo.summercms.io.conf`);
- the Nuxt robots module no longer answers it. Before the first release with the plugin, copy the
- `Disallow` lines the Nuxt config writes today (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`,
+ the site has no Nuxt robots module. Before the first release with the plugin, copy the
+ `Disallow` lines the Nuxt config wrote (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`,
`/_journal`, `/_menu`, `/_faq`, `/_paymentgateway`, `/_paymentgatewaysubscriptions`, `/_feedback`,
`/storage`, `/_presentations`, `/_quizzes`, `/_demo`, `/api/realtime/token`, `/mcp`,
`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/`) into
@@ -674,11 +680,100 @@ the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (
`Disallow: /_forum`, `Disallow: /_userfriends` and `Disallow: /livekit` under `User-agent: *`
(`goPaths` lists the six API prefixes). `/_herald` is not routed and needs no line.
`scripts/check-live.sh` checks the `/_chat` and `/_forum` lines.
-- **404 log.** Set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` in `.env` on rome (a random value, never printed
- or committed) and the same token in the Nuxt site's environment once the frontend layer reports 404s
- (Phase 33); without it the report endpoint stays off.
+- **The site's head comes from the plugin** (vue-seo-layer, through vue-journal-layer, vue-menu-layer and
+ vue-faq-layer). Every page is resolved with the plugin before Nuxt renders it: the title, description,
+ canonical, robots, Open Graph, hreflang and one JSON-LD `@graph` come from the answer, breadcrumbs and
+ pagination are rendered on the server, and redirects and 410s from **SEO › Redirects** apply on the
+ site. Browser navigations resolve through the Nuxt server (`/api/__seo__/resolve`), so `/_seo` has no
+ nginx location. `site.env` names the API (`NUXT_SEO_API_BASE`) and the Go origin of the root files
+ (`NUXT_SEO_BACKEND_BASE`), both on loopback (`deploy/site.env.example`).
+- **404 log and page registry.** `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and
+ `SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN` in `.env`, the same values as `NUXT_SEO_NOTFOUND_TOKEN` and
+ `NUXT_SEO_ROUTE_REPORT_TOKEN` in `site.env` (random, never printed or committed; "One-time server
+ setup" writes them, "Before the first release with Phase 33" below adds them to an existing install).
+ Each 404 page view lands in **SEO › 404 log**; at every start the site reports its static pages (`/`,
+ `/blog`, `/chat`, `/contact`, `/faq`, `/forum`, `/quizzes`), which appear in **SEO › Pages** and the
+ sitemaps. Without a token nothing is reported.
+- **Noindex pages.** The deck routes `/p/**` and `/presentations/**` render in the browser only and are
+ declared in `seo.noindexRoutes` (`vue-demo-app/nuxt.config.ts`; the layer's build guard refuses any other
+ browser-only route, D-70); the auth pages (login, register, connect, link-admin) and the signed-in pages
+ (account security, subscriptions, friends, calls, forum threads) set it in their page meta; quizzes and
+ the roadmap redirect keep `noindex`. All of them answer `X-Robots-Tag: noindex, nofollow` (`noindex`
+ for the two) and stay out of the route report.
+- **Sitemaps** (sm-sitemap-plugin). `/sitemap.xml` is a sitemap index of `/sitemap-<type>-<n>.xml`
+ files (posts, categories, authors, pages), which Nuxt serves from the Go port's `/_sitemap/files/` on
+ loopback: nginx has no `/sitemap*` or `/_sitemap` location (`scripts/check-deploy.sh` checks it) and
+ `/sitemap.xsl` is gone. The addresses start with `golem15.sitemap.site_url`
+ (`config/golem15.sitemap.yaml`, or **Settings › Sitemaps**); without one `/sitemap.xml` answers 404. The
+ demo has no tag pages (`config/golem15.journal.yaml`, `tag_pages: false`), so no tag address is listed.
+ Rebuilds run as jobs in the serve process after each Journal save and nightly. The footer links the
+ HTML sitemap (`/sitemap`, `/mapa-strony` in Polish).
+- **Page titles.** A reported page takes its name from its path ("Home", "Faq") until it has its own SEO
+ title; `deploy/seo-pages.csv` holds the titles and descriptions the pages had before (records by URL,
+ imported once, below).
- **Verify** after the release: `curl -s https://demo.summercms.io/robots.txt` shows the copied lines and
- the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`.
+ the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`; and from
+ the build machine `SMOKE_SEO_API=… scripts/smoke-seo.sh https://demo.summercms.io` (below) ends with
+ `smoke-seo: ok`.
+
+**Before the first release with Phase 33** (the SEO layer and the new sitemap format), on rome as root
+(`$RSH $ROME 'sudo bash -s'` with a quoted heredoc). It adds the two SEO tokens to both files and
+refuses when either already names one, never printing a value:
+
+```sh
+set -euo pipefail
+E=/srv/summercms-demo/.env S=/srv/summercms-demo/site.env
+n=$(cat "$E" "$S" | { grep -c -E '^(SUMMER_GOLEM15__SEO__(NOTFOUND|ROUTE_REPORT)_TOKEN|NUXT_SEO_)' || true; })
+[ "$n" = 0 ] || { echo "$n SEO keys are already set; edit the files by hand"; exit 1; }
+NT=$(openssl rand -hex 32) RT=$(openssl rand -hex 32)
+printf 'SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=%s\nSUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=%s\n' "$NT" "$RT" >>"$E"
+printf 'NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1\nNUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098\n' >>"$S"
+printf 'NUXT_SEO_NOTFOUND_TOKEN=%s\nNUXT_SEO_ROUTE_REPORT_TOKEN=%s\n' "$NT" "$RT" >>"$S"
+unset NT RT
+sed -i '/^NUXT_PUBLIC_SITE_URL=\|^NUXT_PUBLIC_SITEMAP_URL=/d' "$S" # read by nothing since the SEO layer
+```
+
+**After the first release with Phase 33** (the release restarted both programs and installed the vhost
+without the sitemap locations), on rome as root, the same way as "robots.txt without a browser" (the
+admin password through `jq` into a 0700 temp directory). The migration copied the old sitemap
+definition's url items into the additional addresses; the reported pages list `/`, `/blog` and `/faq`
+already and `/subscriptions` is noindex, so they are removed. Then the sitemaps are rebuilt once and the
+page titles imported:
+
+```sh
+set -euo pipefail; umask 077
+D=$(mktemp -d); trap 'rm -rf "$D"' EXIT; G=http://127.0.0.1:8098
+jq -R '{login:"admin",password:.}' </root/summercms-demo-admin.txt >"$D/login.json"
+curl -sf -o "$D/login.out" -X POST -H 'Content-Type: application/json' -d @"$D/login.json" $G/smbackend/api/v1/auth/login
+printf 'Authorization: Bearer %s\n' "$(jq -r .data.access_token "$D/login.out")" >"$D/hdr"
+curl -sf -o "$D/sitemap.json" -H @"$D/hdr" $G/_sitemap/api/v1/settings
+jq -c .data.settings.extra_urls "$D/sitemap.json" # the copied addresses, kept for the record
+jq '{extra_urls: [.data.settings.extra_urls[] | select(.url as $u | ["/", "/blog", "/faq", "/subscriptions"] | index($u) | not)]}' "$D/sitemap.json" >"$D/put.json"
+curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H @"$D/hdr" -H 'Content-Type: application/json' -d @"$D/put.json" $G/_sitemap/api/v1/settings # 200
+(cd /srv/summercms-demo && sudo -u summercms-demo SUMMER_ENV=production ./bin/demo sitemap:rebuild) # Rebuilt the sitemaps: …
+jq -Rs '{dataset: "records", csv: .}' </tmp/seo-pages.csv >"$D/import.json"
+curl -s -o "$D/import.out" -w '%{http_code}\n' -H @"$D/hdr" -H 'Content-Type: application/json' -d @"$D/import.json" $G/_seo/api/v1/import # 200
+jq -c '{applied, summary}' "$D/import.out" # applied true, refused 0
+curl -s -o /dev/null -X POST -H @"$D/hdr" -H 'Content-Type: application/json' -d '{}' $G/smbackend/api/v1/auth/logout
+```
+
+`/tmp/seo-pages.csv` is `deploy/seo-pages.csv`, uploaded first
+(`rsync -av -e "$RSH" deploy/seo-pages.csv $ROME:/tmp/seo-pages.csv`). The import needs the route report
+first: the Nuxt site sends it right after its start (`[vue-seo-layer] route report: 7 routes sent` in its
+log).
+
+**smoke-seo.sh** checks the SEO output through the site the way a visitor and a crawler see it: a Journal
+post's CMS title, one canonical, hreflang, one JSON-LD `@graph` and breadcrumbs; the sitemap index, its
+files and their addresses (no duplicate, none noindex); robots.txt; the noindex routes and pages; the HTML
+sitemap; a redirect it creates through the SEO API (301 from Nuxt, removed again); the reported pages in
+the registry; and a 404 reaching the 404 log (removed again). Locally after `scripts/dev.sh up`:
+`scripts/smoke-seo.sh http://127.0.0.1:3020`. Against rome, from the build machine with the admin
+password in a 0600 file and the Go port reached over an SSH tunnel
+(`$RSH -N -L 18098:127.0.0.1:8098 $ROME &`):
+`SMOKE_SEO_API=http://127.0.0.1:18098 SMOKE_SEO_ADMIN_PASSWORD_FILE=… scripts/smoke-seo.sh https://demo.summercms.io`.
+An alternate or sitemap address that does not answer 200 is a `WARN` line (`SMOKE_SEO_STRICT=1` makes it
+a failure): the Polish addresses (`/pl/…`) the CMS names answer 404 while the site keeps the language in a
+cookie at one address (D-13).
## Build
diff --git a/README.md b/README.md
index 5439013..78aa580 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,8 @@ a login (no public page links to it); `DEPLOY.md` covers the server setup, build
rollback.
- **Go** (`bin/demo`, 127.0.0.1:8097): the admin at `/smbackend` (`backend.uri` in
- `config/backend.yaml`), every plugin's API and `/sitemap.xml`.
+ `config/backend.yaml`), every plugin's API, robots.txt and the sitemaps the site serves at
+ `/sitemap.xml` (DEPLOY.md, "SEO").
- **Nuxt** (`vue-demo-app`, 127.0.0.1:3020): the guided tour home page, journal, FAQ, subscriptions,
registration and login (Turnstile), contact, presentations and quizzes, in English and Polish.
- **Postgres** (docker compose, 127.0.0.1:55540): database, user and password `summercms_demo`.
@@ -32,7 +33,7 @@ is a package of the root module.
| `golem15.moderation` | `plugins/golem15/moderation` (`sm-moderation-plugin`) | Banned words and links, edited in Settings; the demo refuses chat messages and forum posts that contain them |
| `golem15.translate` | `plugins/golem15/translate` | Locales and translatable content |
| `golem15.journal` | `plugins/golem15/journal` | Posts and categories (`/_journal/api/v1`) |
-| `golem15.sitemap` | `plugins/golem15/sitemap` | `/sitemap.xml` from a definition edited in Settings |
+| `golem15.sitemap` | `plugins/golem15/sitemap` | The sitemap index `/sitemap.xml` and its per-type files, built from the content and edited in Settings › Sitemaps |
| `golem15.menu` | `plugins/golem15/menu` | Menus (`/_menu/api/v1`) |
| `golem15.faq` | `plugins/golem15/faq` | FAQs (`/_faq/api/v1`) |
| `golem15.paymentgateway` | `plugins/golem15/paymentgateway` | Orders and payments (`/_paymentgateway/api/v1`) |
@@ -275,7 +276,8 @@ scripts/build.sh # release: bin/demo (linux/amd64) and vue-demo-app/.
scripts/smoke.sh # boot both on a fresh postgres:15 and check the APIs, every server-rendered page and the purge route (no purge token in any page)
scripts/check-pins.sh # every plugin submodule is on the version go.mod requires
scripts/build.sh dev # host build through go.work, no refusals
-scripts/check-deploy.sh # deploy/: nginx -t, supervisor parse, ports, /backend refused, /smbackend to Go, no /oauth or /api/ to Go (but the realtime token), Centrifugo checkconfig and ports, no NUXT_SITE_* key in site.env.example, the LiveKit program, ports and caps, the chat-family locations, no /_herald
+scripts/check-deploy.sh # deploy/: nginx -t, supervisor parse, ports, /backend refused, /smbackend to Go, no /oauth or /api/ to Go (but the realtime token), Centrifugo checkconfig and ports, no NUXT_SITE_* key in site.env.example, the LiveKit program, ports and caps, the chat-family locations, no /_herald, no /sitemap, /_sitemap or /_seo location
+scripts/smoke-seo.sh http://127.0.0.1:3020 # a running site's SEO output: a post's CMS head, one graph and breadcrumbs, the sitemap index and files, robots.txt, noindex routes and pages, the HTML sitemap, a redirect, the page registry and the 404 log (DEPLOY.md, "SEO")
scripts/check-live.sh https://demo.summercms.io # a deployed origin: banner, HSTS, /backend 404, /smbackend admin 200, API 200, /register Turnstile, /contact, Polish, token-less register 422, /roadmap, quizzes, realtime token 401, /ws upgrade, no link to the admin, no purge key in a page, /chat /forum /friends /call prompts, chat-family 401s, /livekit/rtc/validate from LiveKit, no /_herald, robots lines, Back in a browser
```
@@ -305,14 +307,16 @@ scripts/check-live.sh https://demo.summercms.io # a deployed origin: banner, H
`vue-demo-app/.output` on 127.0.0.1:13098 (`SMOKE_SITE_PORT`) against the binary and checks every
server-rendered page (`/`, `/blog`, a post, `/faq`, `/subscriptions`, `/register`, `/login`,
`/contact`, `/quizzes`: banner, test card and backend call to action present; no admin path and
- no visitor credential), the `/roadmap` redirect and the quiz list. The deck pages are client-only;
+ no visitor credential), the `/roadmap` redirect and the quiz list. It runs `sitemap:rebuild` and
+ checks the sitemap index on the binary and, through the site, `/sitemap.xml`, a sitemap file and
+ `/robots.txt`. The deck pages are client-only (an empty app root, `X-Robots-Tag: noindex, nofollow`);
`scripts/dev.sh snapshot --site` checks their compact banner (`deck-snapshot.cjs --expect-banner`).
The site runs with two throwaway purge tokens, `NUXT_CACHE_PURGE_TOKEN` and the deprecated
- `NUXT_SITE_CACHE_PURGE_TOKEN`: no server-rendered page and no deck page
- (`window.__NUXT_SITE_CONFIG__`) may carry either value or a purge key name (`cachePurgeToken`,
+ `NUXT_SITE_CACHE_PURGE_TOKEN`: no server-rendered page and no deck page may carry either value or a purge key name (`cachePurgeToken`,
`cacheAllowProxied`), the new token purges (200) and the deprecated one gets 401, and the site log
- has no value and no deprecation warning (the new name wins, so the old one is ignored). nuxt-site-config publishes every `NUXT_SITE_*` variable
- in the page payload, which is how the old name leaked before vue-journal-layer 0.6.0.
+ has no value and no deprecation warning (the new name wins, so the old one is ignored). nuxt-site-config
+ (gone with vue-journal-layer 0.8.0) published every `NUXT_SITE_*` variable in the page payload, which
+ is how the old name leaked before vue-journal-layer 0.6.0.
It ends with `smoke: ok`.
- **Local runs** export `SUMMER_ENV=local` (`scripts/dev.sh`), so the production overlay in
`config/env/production/` (debug off, the public origin, CORS, Stripe return URLs) applies only on
diff --git a/config/golem15.journal.yaml b/config/golem15.journal.yaml
new file mode 100644
index 0000000..c7edb31
--- /dev/null
+++ b/config/golem15.journal.yaml
@@ -0,0 +1,7 @@
+# golem15.journal (sm-journal-plugin): the demo's Journal.
+#
+# The site has no tag pages (vue-demo-app nuxt.config.ts: journal.routes.tag
+# is false), so tag URLs resolve to nothing: the sitemaps, the SEO plugin's
+# resolve and the menu item types never name /blog/tag/... addresses that
+# answer 404. Author pages stay on (/blog/author/:author).
+tag_pages: false
diff --git a/config/golem15.seo.yaml b/config/golem15.seo.yaml
index dbea376..b99938f 100644
--- a/config/golem15.seo.yaml
+++ b/config/golem15.seo.yaml
@@ -2,11 +2,20 @@
# URL rules, redirects, the 404 log, robots.txt and the SEO MCP tools.
#
# site_url is the public origin the Nuxt site serves; canonical URLs,
-# Open Graph URLs and robots.txt Sitemap lines are built from it, never from
-# a request's Host. The 404 report token comes from the environment
-# (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN, .env on rome; never committed);
-# without it the report endpoint is off.
+# Open Graph URLs, hreflang alternates, the JSON-LD graph and robots.txt
+# Sitemap lines are built from it, never from a request's Host. Locally,
+# override it with SUMMER_GOLEM15__SEO__SITE_URL (http://127.0.0.1:3020).
+#
+# Both tokens come from the environment (.env on rome; never committed) and
+# the Nuxt site gets the same values (site.env, NUXT_SEO_NOTFOUND_TOKEN and
+# NUXT_SEO_ROUTE_REPORT_TOKEN):
+# - notfound_token (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN): the 404 report;
+# empty turns the endpoint off.
+# - route_report_token (SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN): the route
+# report vue-seo-layer posts at server start, which lists the site's static
+# pages in SEO › Pages; empty falls back to notfound_token.
site_url: https://demo.summercms.io
notfound_token: ""
+route_report_token: ""
robots_sitemaps:
- /sitemap.xml
diff --git a/config/golem15.sitemap.yaml b/config/golem15.sitemap.yaml
new file mode 100644
index 0000000..1c09ca2
--- /dev/null
+++ b/config/golem15.sitemap.yaml
@@ -0,0 +1,15 @@
+# golem15.sitemap (sm-sitemap-plugin): the sitemap index at /sitemap.xml and
+# its per-type files /sitemap-<type>-<n>.xml, built from the Journal posts,
+# categories and authors, the SEO plugin's page registry and the additional
+# addresses (Settings › Sitemaps).
+#
+# site_url is the public origin every <loc> starts with (the Site address
+# setting wins when it is set); without either, /sitemap.xml answers 404. The
+# request's Host is never used. Locally, override it with
+# SUMMER_GOLEM15__SITEMAP__SITE_URL (http://127.0.0.1:3020).
+#
+# Rebuilds run as conga jobs in the serve process (config/queue.yaml
+# work_in_serve) after every Journal save and nightly; after a release that
+# changes the sitemap format, run `bin/demo sitemap:rebuild` once (DEPLOY.md,
+# "SEO").
+site_url: https://demo.summercms.io
diff --git a/deploy/env.example b/deploy/env.example
index cdf6238..ada8493 100644
--- a/deploy/env.example
+++ b/deploy/env.example
@@ -34,6 +34,12 @@ SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET=
# "Rotating the site-cache purge token").
SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3098/api/__site__/cache/purge
SUMMER_SITE_CACHE__PURGE_TOKEN=
+# golem15.seo (config/golem15.seo.yaml): the 404 report token and the route
+# report token, the same values as NUXT_SEO_NOTFOUND_TOKEN and
+# NUXT_SEO_ROUTE_REPORT_TOKEN in site.env (openssl rand -hex 32). Empty: the
+# 404 log gets no reports and SEO › Pages lists no static pages.
+SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=
+SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=
# Cloudflare Turnstile on register and login (D-07; golem15.user.turnstile is
# closed in config/golem15.user.yaml): the secret key of the Turnstile widget
# for demo.summercms.io, from the operator's secrets file. Without it every
diff --git a/deploy/nginx/demo.summercms.io.conf b/deploy/nginx/demo.summercms.io.conf
index 3dbedf2..20d4e56 100644
--- a/deploy/nginx/demo.summercms.io.conf
+++ b/deploy/nginx/demo.summercms.io.conf
@@ -3,8 +3,8 @@
# - summercms-demo, the Go binary, on 127.0.0.1:8098 (supervisor program
# summercms-demo): only the allow-listed prefixes below (the admin at
# /smbackend, plugin APIs including the chat family, the demo API, the
-# realtime token route, Stripe webhooks, public uploads, the sitemap,
-# the MCP endpoint and its OAuth paths);
+# realtime token route, Stripe webhooks, public uploads, robots.txt,
+# the MCP endpoint and its OAuth paths); the sitemaps go through Nuxt;
# - summercms-demo-centrifugo, the demo's own Centrifugo, on 127.0.0.1:8010
# (supervisor program summercms-demo-centrifugo, D-17): the WebSocket at
# /ws of the presentations' speaker view and phone remote, the chat, the
@@ -405,24 +405,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # The sitemap plugin's document and its stylesheet.
- location = /sitemap.xml {
- proxy_pass http://127.0.0.1:8098;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- location = /sitemap.xsl {
- proxy_pass http://127.0.0.1:8098;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
+ # The sitemaps (/sitemap.xml, the index, and its /sitemap-<type>-<n>.xml
+ # files), llms.txt and the verification files reach Nuxt through
+ # location /: vue-seo-layer fetches them from the Go port on loopback
+ # (/_sitemap/files/ and the SEO plugin's API), so neither /_sitemap nor
+ # /_seo has a location here (scripts/check-deploy.sh checks it).
# Nuxt: pages, /_nuxt assets, fonts. GET and HEAD only, so the layers' POST
# routes (the site-cache purge, called by Go on loopback) stay unreachable.
diff --git a/deploy/seo-pages.csv b/deploy/seo-pages.csv
new file mode 100644
index 0000000..c98c558
--- /dev/null
+++ b/deploy/seo-pages.csv
@@ -0,0 +1,3 @@
+url,locale,title,description
+/,en,SummerCMS Demo,A guided tour of SummerCMS: every shared Golem15 plugin on one Go binary.
+/faq,en,FAQ,"Managed in the admin under FAQ, one set per language; served by the FAQ API and rendered with vue-faq-layer."
diff --git a/deploy/site.env.example b/deploy/site.env.example
index 05b34c5..46d7b37 100644
--- a/deploy/site.env.example
+++ b/deploy/site.env.example
@@ -10,10 +10,18 @@ NUXT_PAYMENTGATEWAY_API_BASE=http://127.0.0.1:8098/_paymentgateway/api/v1
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE=http://127.0.0.1:8098/_paymentgatewaysubscriptions/api/v1
# The demo's own API (golem15.demo): /roadmap and /quizzes read it on the server.
NUXT_DEMO_API_BASE=http://127.0.0.1:8098/_demo/api/v1
-# The public origin (canonical URLs, sitemap and robots of the SEO module) and
-# the footer's sitemap link (nginx sends /sitemap.xml to Go).
-NUXT_PUBLIC_SITE_URL=https://demo.summercms.io
-NUXT_PUBLIC_SITEMAP_URL=/sitemap.xml
+# vue-seo-layer: the SEO plugin's API (resolve, site answer, 404 and route
+# reports) and the Go origin of the root files (robots.txt, the sitemaps);
+# the head, canonical and sitemap URLs come from golem15.seo.site_url and
+# golem15.sitemap.site_url on the binary, never from here.
+NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1
+NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098
+# The same values as SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN and
+# SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN in .env (openssl rand -hex 32,
+# generated on rome, never printed): the 404 report and the route report of
+# the static pages to SEO › Pages. Empty: nothing is reported.
+NUXT_SEO_NOTFOUND_TOKEN=
+NUXT_SEO_ROUTE_REPORT_TOKEN=
# Site cache purge token: the same value as SUMMER_SITE_CACHE__PURGE_TOKEN in
# .env, generated on rome (openssl rand -hex 32) and never printed. Fewer
# than 32 characters disables the purge route. The name is deliberately not
diff --git a/scripts/check-deploy.sh b/scripts/check-deploy.sh
index 417cbc5..0d7d801 100755
--- a/scripts/check-deploy.sh
+++ b/scripts/check-deploy.sh
@@ -39,6 +39,8 @@
# - each chat-family prefix (/_chat/, /_chatchannels/, /_chatbots/,
# /_chatvideo/, /_forum/, /_userfriends/) is a location to the Go port,
# and no location routes /_herald (D-16: no subscribe UI);
+# - no location routes /_seo, /_sitemap or /sitemap*: vue-seo-layer serves
+# the sitemaps through Nuxt from the Go port on loopback;
# - livekit.yaml.example holds placeholder keys only, and env.example
# declares the LiveKit and VAPID keys empty.
# Each LiveKit and chat-family mismatch prints its own line.
@@ -405,6 +407,10 @@ for prefix in ("_chat", "_chatchannels", "_chatbots", "_chatvideo", "_forum", "_
for loc in re.findall(r"location\s+(?:[=~^*]+\s+)?(\S+)\s*\{", code):
if loc.startswith("/_herald"):
problems.append(f"nginx routes {loc}: /_herald stays unrouted (D-16, no subscribe UI)")
+ # vue-seo-layer serves the sitemaps from the Go port on loopback and
+ # resolves through the Nuxt server (DEPLOY.md, "SEO").
+ if loc.startswith(("/_seo", "/_sitemap", "/sitemap")):
+ problems.append(f"nginx routes {loc}: the sitemaps and the SEO API reach Go through Nuxt (vue-seo-layer), not nginx")
if problems:
sys.exit("\n".join(problems))
diff --git a/scripts/smoke-seo.sh b/scripts/smoke-seo.sh
new file mode 100755
index 0000000..55af385
--- /dev/null
+++ b/scripts/smoke-seo.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+# The SEO output of a running demo, checked through its Nuxt site the way a
+# visitor and a crawler see it (Phase 33: vue-seo-layer, golem15.seo,
+# golem15.sitemap):
+#
+# scripts/smoke-seo.sh BASE_URL e.g. http://127.0.0.1:3020
+#
+# - a Journal post: the CMS title, one canonical, hreflang, one JSON-LD
+# @graph, server-rendered breadcrumbs and the CMS's X-Robots-Tag;
+# - /sitemap.xml is a sitemap index whose files answer through the site, and
+# robots.txt names it;
+# - a redirect created through the SEO API answers 301 from Nuxt (removed
+# again at the end);
+# - the declared browser-only routes (/p/**, /presentations/**) and the auth
+# and signed-in pages answer noindex, nofollow;
+# - the route report put the static pages into the page registry, and a 404
+# reaches the 404 log (the entry is removed again);
+# - the HTML sitemap page and the footer link to it.
+#
+# The API checks talk to the Go binary directly:
+# SMOKE_SEO_API Go origin (default http://127.0.0.1:8097, scripts/dev.sh)
+# SMOKE_SEO_ADMIN_LOGIN admin login (default admin)
+# SMOKE_SEO_ADMIN_PASSWORD_FILE a file holding the admin password, else
+# SMOKE_SEO_ADMIN_PASSWORD the password (default: scripts/dev.sh's local admin)
+# SMOKE_SEO_POST the post path to check (default: the first post
+# with a category, from the Journal API)
+# SMOKE_SEO_STRICT=1 turn WARN lines (alternates and sitemap
+# addresses that do not answer 200) into failures
+#
+# Prints "ok ..." per check, "WARN ..." and "FAIL ..." lines, and ends with
+# "smoke-seo: ok" (exit 0) or "smoke-seo: N failed" (exit 1). No secret is
+# printed or passed on a command line.
+set -euo pipefail
+
+[ $# -eq 1 ] || { echo "usage: scripts/smoke-seo.sh BASE_URL" >&2; exit 2; }
+BASE=${1%/}
+API=${SMOKE_SEO_API:-http://127.0.0.1:8097}
+API=${API%/}
+STRICT=${SMOKE_SEO_STRICT:-0}
+command -v python3 >/dev/null || { echo "smoke-seo: python3 not found" >&2; exit 2; }
+
+D=$(mktemp -d)
+HDR="$D/auth.hdr"
+trap 'logout; rm -rf "$D"' EXIT
+failed=0
+ok() { echo "ok $*"; }
+fail() { echo "FAIL $*"; failed=$((failed + 1)); }
+warn() {
+ if [ "$STRICT" = 1 ]; then fail "$*"; else echo "WARN $*"; fi
+}
+
+# get PATH_OR_URL NAME: GET without following redirects into $D/NAME.body and
+# $D/NAME.head; prints the status.
+get() {
+ local url=$1
+ case "$url" in http://* | https://*) ;; *) url="$BASE$url" ;; esac
+ curl -s -o "$D/$2.body" -D "$D/$2.head" -w '%{http_code}' -H 'Accept: text/html' "$url" || echo 000
+}
+# probe PATH: the status of PATH without Accept: text/html, so the layer
+# does not report a 404 to the 404 log (alternates and sitemap addresses);
+# the headers land in $D/probe.head.
+probe() { curl -s -o /dev/null -D "$D/probe.head" -w '%{http_code}' -H 'Accept: */*' "$BASE$1" || echo 000; }
+header() { tr -d '\r' <"$D/$1.head" | awk -v k="$(echo "$2" | tr 'A-Z' 'a-z')" -F': ' 'tolower($1)==k {sub(/^[^:]*: /, ""); print; exit}'; }
+count() { { grep -o -- "$2" "$D/$1.body" || true; } | wc -l | tr -d ' '; }
+# py NAME SCRIPT: runs SCRIPT with the body of NAME on stdin.
+py() { python3 -c "$2" <"$D/$1.body"; }
+
+# --- the admin token (redirect, registry and 404 log checks) ----------------
+login() {
+ local pw
+ if [ -n "${SMOKE_SEO_ADMIN_PASSWORD_FILE:-}" ]; then
+ pw=$(head -n1 "$SMOKE_SEO_ADMIN_PASSWORD_FILE")
+ else
+ pw=${SMOKE_SEO_ADMIN_PASSWORD:-AdminDemo123!}
+ fi
+ SMOKE_LOGIN=${SMOKE_SEO_ADMIN_LOGIN:-admin} SMOKE_PW="$pw" python3 -c '
+import json, os
+print(json.dumps({"login": os.environ["SMOKE_LOGIN"], "password": os.environ["SMOKE_PW"]}))' >"$D/login.json"
+ unset pw
+ LOGIN_STATUS=$(umask 077 && curl -s -o "$D/login.out" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
+ -d @"$D/login.json" "$API/smbackend/api/v1/auth/login" || echo 000)
+ rm -f "$D/login.json"
+ python3 -c '
+import json, sys
+try:
+ print("Authorization: Bearer " + json.load(open(sys.argv[1]))["data"]["access_token"])
+except Exception:
+ sys.exit(1)' "$D/login.out" >"$HDR" 2>/dev/null || { rm -f "$HDR"; return 1; }
+ rm -f "$D/login.out"
+}
+logout() {
+ [ -f "$HDR" ] || return 0
+ curl -s -o /dev/null -X POST -H @"$HDR" -H 'Content-Type: application/json' -d '{}' "$API/smbackend/api/v1/auth/logout" || true
+ rm -f "$HDR"
+}
+api() { # METHOD PATH [JSON_FILE] -> body in $D/api.out, prints the status
+ local args=(-s -o "$D/api.out" -w '%{http_code}' -X "$1" -H @"$HDR")
+ [ $# -ge 3 ] && args+=(-H 'Content-Type: application/json' -d @"$3")
+ curl "${args[@]}" "$API$2" || echo 000
+}
+
+# --- 1. a Journal post ----------------------------------------------------
+POST=${SMOKE_SEO_POST:-}
+if [ -z "$POST" ]; then
+ curl -s -o "$D/posts.json" "$API/_journal/api/v1/posts" || true
+ POST=$(python3 -c '
+import json, sys
+try:
+ posts = json.load(open(sys.argv[1]))["data"]
+except Exception:
+ sys.exit(0)
+for p in posts:
+ if p.get("categories"):
+ print("/blog/" + p["slug"]); break
+else:
+ if posts: print("/blog/" + posts[0]["slug"])' "$D/posts.json")
+fi
+[ -n "$POST" ] || { fail "no Journal post found through $API/_journal/api/v1/posts (set SMOKE_SEO_POST)"; POST=/blog/missing; }
+
+curl -s -o "$D/resolve.json" "$API/_seo/api/v1/resolve?url=$POST" || true
+CMS_TITLE=$(python3 -c '
+import json, sys
+try:
+ a = json.load(open(sys.argv[1]))
+ print(a["seo"]["title"] if a.get("action") == "ok" else "")
+except Exception:
+ print("")' "$D/resolve.json")
+CMS_XRT=$(python3 -c '
+import json, sys
+try: print(json.load(open(sys.argv[1]))["seo"]["x_robots_tag"])
+except Exception: print("")' "$D/resolve.json")
+
+st=$(get "$POST" post)
+[ "$st" = 200 ] && ok "$POST answers 200" || fail "$POST answers $st, not 200"
+n=$(count post '<script type="application/ld+json"'); [ "$n" = 1 ] && ok "$POST has one ld+json script" || fail "$POST has $n ld+json scripts, not 1"
+py post 'import re, sys, json
+m = re.search(r"<script type=\"application/ld\+json\"[^>]*>(.*?)</script>", sys.stdin.read(), re.S)
+g = json.loads(m.group(1)) if m else {}
+sys.exit(0 if isinstance(g.get("@graph"), list) and len(g["@graph"]) > 1 else 1)' &&
+ ok "$POST ld+json is one @graph" || fail "$POST ld+json is not one @graph with nodes"
+n=$(count post 'rel="canonical"'); [ "$n" = 1 ] && ok "$POST has one canonical" || fail "$POST has $n canonical links, not 1"
+n=$(count post 'hreflang="'); [ "$n" -ge 2 ] && ok "$POST has $n hreflang links" || fail "$POST has $n hreflang links, fewer than 2"
+n=$(count post 'class="seo-breadcrumbs__link"')
+grep -q '<nav class="seo-breadcrumbs"' "$D/post.body" && [ "$n" -ge 1 ] &&
+ ok "$POST has nav.seo-breadcrumbs ($n links)" || fail "$POST has no server-rendered nav.seo-breadcrumbs with links"
+title=$(py post 'import re, sys, html
+m = re.search(r"<title>(.*?)</title>", sys.stdin.read(), re.S)
+print(html.unescape(m.group(1)).strip() if m else "")')
+if [ -n "$CMS_TITLE" ] && [ "$title" = "$CMS_TITLE" ]; then ok "$POST title is the CMS title ($title)"; else fail "$POST title '$title' is not the CMS title '$CMS_TITLE'"; fi
+xrt=$(header post X-Robots-Tag)
+[ -n "$CMS_XRT" ] && [ "$xrt" = "$CMS_XRT" ] && ok "$POST X-Robots-Tag is the CMS's ($xrt)" || fail "$POST X-Robots-Tag '$xrt' is not the CMS's '$CMS_XRT'"
+
+# The alternates should answer 200 on this site.
+for alt in $(py post 'import re, sys
+for m in re.finditer(r"<link rel=\"alternate\" hreflang=\"[^\"]+\" href=\"([^\"]+)\"", sys.stdin.read()):
+ print(m.group(1))' | sort -u); do
+ path=$(python3 -c 'import sys, urllib.parse as u; p = u.urlsplit(sys.argv[1]); print(p.path + ("?" + p.query if p.query else ""))' "$alt")
+ st=$(probe "$path")
+ [ "$st" = 200 ] && ok "alternate $path answers 200" || warn "alternate $path answers $st"
+done
+
+# --- 2. sitemaps and robots.txt ---------------------------------------------
+st=$(get /sitemap.xml index)
+if [ "$st" = 200 ] && grep -q '<sitemapindex' "$D/index.body"; then
+ ok "/sitemap.xml is a sitemap index ($(header index Content-Type))"
+else
+ fail "/sitemap.xml answers $st without <sitemapindex>"
+fi
+files=$(py index 'import re, sys, urllib.parse as u
+for m in re.findall(r"<loc>([^<]+)</loc>", sys.stdin.read()):
+ print(u.urlsplit(m).path)')
+[ -n "$files" ] || fail "/sitemap.xml lists no sitemap file"
+addresses=0
+bad=0
+for f in $files; do
+ st=$(get "$f" file)
+ if [ "$st" = 200 ] && grep -q '<urlset' "$D/file.body"; then
+ ok "$f answers a urlset through the site"
+ else
+ fail "$f answers $st without <urlset>"
+ continue
+ fi
+ for loc in $(py file 'import re, sys, urllib.parse as u
+for m in re.findall(r"<loc>([^<]+)</loc>", sys.stdin.read()):
+ print(u.urlsplit(m).path)' | head -200); do
+ addresses=$((addresses + 1))
+ echo "$loc" >>"$D/locs.txt"
+ st=$(probe "$loc")
+ [ "$st" = 200 ] || { warn "sitemap address $loc answers $st"; bad=$((bad + 1)); }
+ case "$(header probe X-Robots-Tag)" in
+ *noindex*) fail "sitemap address $loc answers X-Robots-Tag: $(header probe X-Robots-Tag)" ;;
+ esac
+ done
+done
+[ "$addresses" -gt 0 ] && ok "$addresses sitemap addresses fetched, $bad not 200" || fail "the sitemap files list no address"
+dups=$( [ -f "$D/locs.txt" ] && sort "$D/locs.txt" | uniq -d | tr '\n' ' ')
+[ -z "$dups" ] && ok "no address is listed twice in the sitemaps" || fail "addresses listed more than once in the sitemaps: $dups"
+st=$(get /robots.txt robots)
+if [ "$st" = 200 ] && grep -q '^Sitemap: .*/sitemap\.xml$' "$D/robots.body"; then
+ ok "/robots.txt names the sitemap index"
+else
+ fail "/robots.txt answers $st without a Sitemap: .../sitemap.xml line"
+fi
+
+# --- 3. noindex routes, auth and signed-in pages ------------------------------
+for p in /p/smoke-seo /presentations/new /login /register /account/security; do
+ st=$(get "$p" noindex)
+ xrt=$(header noindex X-Robots-Tag)
+ [ "$xrt" = "noindex, nofollow" ] && ok "$p ($st) sends X-Robots-Tag: noindex, nofollow" ||
+ fail "$p ($st) sends X-Robots-Tag '$xrt', not 'noindex, nofollow'"
+done
+get /login noindex >/dev/null
+grep -q '<meta name="robots" content="noindex, nofollow">' "$D/noindex.body" &&
+ ok "/login has the robots meta noindex, nofollow" || fail "/login has no robots meta noindex, nofollow"
+
+# --- 4. the HTML sitemap and the footer link ---------------------------------
+st=$(get / home)
+grep -q 'class="seo-sitemap-link"' "$D/home.body" && ok "/ ($st) links the HTML sitemap in the footer" || fail "/ ($st) has no a.seo-sitemap-link"
+href=$(py home 'import re, sys
+m = re.search(r"<a href=\"([^\"]+)\"[^>]*class=\"seo-sitemap-link\"|class=\"seo-sitemap-link\"[^>]*href=\"([^\"]+)\"", sys.stdin.read())
+print((m.group(1) or m.group(2)) if m else "")')
+if [ -n "$href" ]; then
+ st=$(get "$href" html)
+ [ "$st" = 200 ] && grep -q "href=\"$POST\"" "$D/html.body" && ok "$href lists $POST" || fail "$href answers $st without a link to $POST"
+fi
+
+# --- 5. through the SEO API: a redirect, the registry, the 404 log ----------
+if login; then
+ src="/smoke-seo-old-$(date +%s)-$$"
+ printf '{"source":"%s","target":"%s","status":301,"note":"scripts/smoke-seo.sh"}' "$src" "$POST" >"$D/redirect.json"
+ st=$(api POST /_seo/api/v1/redirects "$D/redirect.json")
+ rid=$(python3 -c 'import json, sys
+try: print(json.load(open(sys.argv[1]))["data"]["id"])
+except Exception: print("")' "$D/api.out")
+ if [ -n "$rid" ]; then
+ st=$(get "$src" redirect)
+ loc=$(header redirect Location)
+ [ "$st" = 301 ] && [ "${loc%"$POST"}" != "$loc" ] && ok "$src answers 301 to $loc (SEO plugin redirect $rid)" ||
+ fail "$src answers $st with Location '$loc', not 301 to $POST"
+ st=$(api DELETE "/_seo/api/v1/redirects/$rid")
+ case "$st" in 2*) ;; *) fail "could not delete redirect $rid ($st)" ;; esac
+ else
+ fail "creating a redirect through the SEO API answered $st"
+ fi
+
+ st=$(api GET '/_seo/api/v1/pages?per_page=200')
+ python3 -c '
+import json, sys
+rows = json.load(open(sys.argv[1]))["data"]
+urls = {r["url"]: r for r in rows}
+need = ["/", "/faq", "/blog"]
+missing = [u for u in need if u not in urls or not urls[u].get("reported_at")]
+leaked = [u for u in ("/login", "/register", "/account/security", "/subscriptions") if u in urls]
+if missing or leaked:
+ print("missing=%s leaked=%s" % (missing, leaked)); sys.exit(1)
+print(" ".join(sorted(u for u, r in urls.items() if r.get("origin") == "reported")))' "$D/api.out" >"$D/registry.txt" 2>&1 &&
+ ok "the registry lists the reported routes: $(cat "$D/registry.txt")" ||
+ fail "the registry ($st) lacks reported routes or lists noindex pages: $(cat "$D/registry.txt")"
+
+ miss="/smoke-seo-missing-$(date +%s)-$$"
+ st=$(get "$miss" missing)
+ [ "$st" = 404 ] && ok "$miss answers 404" || fail "$miss answers $st, not 404"
+ nid=""
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
+ api GET "/_seo/api/v1/notfound?search=$miss" >/dev/null
+ nid=$(python3 -c 'import json, sys
+try:
+ rows = [r for r in json.load(open(sys.argv[1]))["data"] if r.get("url") == sys.argv[2]]
+ print(rows[0]["id"] if rows else "")
+except Exception: print("")' "$D/api.out" "$miss")
+ [ -n "$nid" ] && break
+ sleep 0.5
+ done
+ if [ -n "$nid" ]; then
+ ok "$miss reached the 404 log (entry $nid)"
+ api DELETE "/_seo/api/v1/notfound/$nid" >/dev/null
+ else
+ fail "$miss did not reach the 404 log (is NUXT_SEO_NOTFOUND_TOKEN the backend's notfound_token?)"
+ fi
+else
+ fail "admin login at $API/smbackend/api/v1/auth/login answered ${LOGIN_STATUS:-000} (429: the admin login throttle, wait a minute): no redirect, registry or 404 log checks"
+fi
+
+if [ "$failed" -eq 0 ]; then
+ echo "smoke-seo: ok"
+else
+ echo "smoke-seo: $failed failed"
+ exit 1
+fi
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
index e2bec68..c344ec7 100755
--- a/scripts/smoke.sh
+++ b/scripts/smoke.sh
@@ -1,14 +1,16 @@
#!/usr/bin/env bash
# Boot the built binary on a fresh postgres:15 the way a release does
-# (migrate, admin:create, demo:seed, serve), check the plugin APIs over HTTP,
-# then boot the built Nuxt site (vue-demo-app/.output) against that binary and
-# check its pages.
+# (migrate, admin:create, demo:seed, sitemap:rebuild, serve), check the plugin
+# APIs and the sitemap index over HTTP, then boot the built Nuxt site
+# (vue-demo-app/.output) against that binary and check its pages and the root
+# files it serves from the binary (vue-seo-layer: /sitemap.xml, a sitemap
+# file, /robots.txt).
#
# The site starts with two throwaway purge tokens: NUXT_CACHE_PURGE_TOKEN and
-# the deprecated NUXT_SITE_CACHE_PURGE_TOKEN (vue-journal-layer 0.6.0 still
-# reads it for one release). nuxt-site-config publishes every NUXT_SITE_*
-# variable in the page payload, so no server-rendered page and no
-# client-only page (window.__NUXT_SITE_CONFIG__) may carry either value or a
+# the deprecated NUXT_SITE_CACHE_PURGE_TOKEN (still read for one release).
+# nuxt-site-config published every NUXT_SITE_* variable in the page payload
+# until vue-journal-layer 0.8.0 dropped it, so no server-rendered page and no
+# client-only page may carry either value or a
# purge key name; the new token must purge (200) and the deprecated one,
# shadowed by it, must get 401; the site log carries no token value and no
# deprecation warning (the layer warns only when a setting comes from an old
@@ -132,6 +134,11 @@ run demo:seed >"$TMP/seed.log" 2>&1 || {
cat "$TMP/seed.log" >&2
fail "demo:seed failed"
}
+# The sitemap files exist after the first rebuild (DEPLOY.md, "SEO").
+run sitemap:rebuild >"$TMP/sitemap.log" 2>&1 || {
+ cat "$TMP/sitemap.log" >&2
+ fail "sitemap:rebuild failed"
+}
free_port "$PORT" SMOKE_PORT
(cd "$WORK" && exec env -i PATH="$PATH" HOME="$TMP" SUMMER_ENV=production "$BIN" serve --addr "127.0.0.1:$PORT") >"$TMP/serve.log" 2>&1 &
@@ -169,7 +176,12 @@ check() {
# The plugin APIs the site and the decks read.
check "$BASE/_journal/api/v1/posts" 200 application/json
check "$BASE/_faq/api/v1/faqs/home" 200 application/json
+# The sitemap index on the public origin (config/golem15.sitemap.yaml), its
+# files under /_sitemap/files/ (the site maps the root names).
check "$BASE/sitemap.xml" 200
+grep -qF '<loc>https://demo.summercms.io/sitemap-posts-1.xml</loc>' "$TMP/body" || fail "GET $BASE/sitemap.xml: not an index listing sitemap-posts-1.xml"
+check "$BASE/_sitemap/files/sitemap-posts-1.xml" 200
+grep -qF '/blog/welcome-to-summercms</loc>' "$TMP/body" || fail "GET $BASE/_sitemap/files/sitemap-posts-1.xml: no seeded post"
HASH="$(psql "SELECT public_hash FROM golem15_presentations_presentations WHERE slug = 'roadmap' AND is_published")"
[[ -n "$HASH" ]] || fail "demo:seed published no roadmap deck"
check "$BASE/_presentations/api/v1/public/roadmap-$HASH" 200 application/json
@@ -197,6 +209,7 @@ free_port "$SITE_PORT" SMOKE_SITE_PORT
NUXT_PAYMENTGATEWAY_API_BASE="$BASE/_paymentgateway/api/v1" \
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE="$BASE/_paymentgatewaysubscriptions/api/v1" \
NUXT_DEMO_API_BASE="$BASE/_demo/api/v1" \
+ NUXT_SEO_API_BASE="$BASE/_seo/api/v1" \
NUXT_CACHE_PURGE_TOKEN="$PURGE_TOKEN" \
NUXT_SITE_CACHE_PURGE_TOKEN="$OLD_PURGE_TOKEN" \
node "$SITE_OUT/server/index.mjs") >"$TMP/site.log" 2>&1 &
@@ -217,6 +230,12 @@ fi
check "$SITE/" 200 text/html
+# vue-seo-layer serves the root files from the binary (NUXT_SEO_API_BASE).
+check "$SITE/sitemap.xml" 200 application/xml
+grep -qF '<sitemapindex' "$TMP/body" || fail "GET $SITE/sitemap.xml: not a sitemap index"
+check "$SITE/sitemap-posts-1.xml" 200 application/xml
+check "$SITE/robots.txt" 200 text/plain
+
# no_purge_leak LABEL fails when the last body carries a purge token value or
# a purge key name (cachePurgeToken, cacheAllowProxied...). Never prints a
# value; a key name match never spans one.
@@ -253,7 +272,10 @@ done
check "$SITE/roadmap" 302
grep -qi "^location: /p/roadmap-$HASH" "$TMP/headers" || fail "GET $SITE/roadmap: not a redirect to /p/roadmap-$HASH"
check "$SITE/p/roadmap-$HASH" 200 text/html
-grep -qF '__NUXT_SITE_CONFIG__' "$TMP/body" || fail "GET $SITE/p/roadmap-$HASH: no window.__NUXT_SITE_CONFIG__ (is the deck page still client-only?)"
+grep -qF '<div id="__nuxt"></div>' "$TMP/body" || fail "GET $SITE/p/roadmap-$HASH: the app root is rendered (is the deck page still client-only?)"
+# The deck routes render in the browser only, so seo.noindexRoutes declares
+# them (vue-seo-layer, D-70): served with X-Robots-Tag noindex, nofollow.
+tr -d '\r' <"$TMP/headers" | grep -qix 'x-robots-tag: noindex, nofollow' || fail "GET $SITE/p/roadmap-$HASH: no X-Robots-Tag: noindex, nofollow"
no_purge_leak "$SITE/p/roadmap-$HASH"
check "$SITE/quizzes" 200 text/html
grep -qF 'SummerCMS basics' "$TMP/body" || fail "GET $SITE/quizzes: the quiz list is not rendered"
diff --git a/seo_output_test.go b/seo_output_test.go
new file mode 100644
index 0000000..c7c5192
--- /dev/null
+++ b/seo_output_test.go
@@ -0,0 +1,171 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/surf"
+)
+
+// demoSite is the public origin config/golem15.seo.yaml and
+// config/golem15.sitemap.yaml name.
+const demoSite = "https://demo.summercms.io"
+
+// TestSEOOutput checks the SEO output the Nuxt site reads from the demo
+// binary (Phase 33, SC-1/SC-2) on the seeded content: /sitemap.xml is a
+// sitemap index of per-type files on the public origin and a file lists
+// the Journal posts, while a tagged post adds no tag address and the tag's
+// path does not resolve (golem15.journal.tag_pages is false in
+// config/golem15.journal.yaml); the SEO plugin's site answer names the
+// origin, the locales and the HTML sitemap; a seeded post resolves with
+// one JSON-LD @graph and breadcrumbs through its category; and the route
+// report is accepted with golem15.seo.route_report_token.
+func TestSEOOutput(t *testing.T) {
+ skipUnlessSEOMounted(t)
+ const reportToken = "seo-output-test-route-report-token"
+ demo := bootDemo(t, visitorEnv, map[string]any{"golem15.seo.route_report_token": reportToken})
+ mustSeed(t, demo)
+ // demo:seed tags no post; one tag makes the tag_pages check meaningful.
+ var tagID uint
+ if err := demo.DB.Raw(`INSERT INTO golem15_journal_tags (name, slug, created_at, updated_at)
+VALUES ('Releases', 'releases', NOW(), NOW()) RETURNING id`).Scan(&tagID).Error; err != nil || tagID == 0 {
+ t.Fatalf("tag: id=%d err=%v", tagID, err)
+ }
+ if err := demo.DB.Exec(`INSERT INTO golem15_journal_posts_tags (post_id, tag_id)
+SELECT id, ? FROM golem15_journal_posts WHERE slug = 'welcome-to-summercms'`, tagID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if out, err := runCommand(t, demo, "sitemap:rebuild"); err != nil {
+ t.Fatalf("sitemap:rebuild: %v\n%s", err, out)
+ }
+ h, err := surf.Assemble(demo.App, demo.Plugins)
+ if err != nil {
+ t.Fatalf("surf.Assemble: %v", err)
+ }
+ do := func(method, path, body string, header map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ for k, v := range header {
+ req.Header.Set(k, v)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+ }
+ get := func(path string) string {
+ t.Helper()
+ rec := do(http.MethodGet, path, "", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d %s", path, rec.Code, rec.Body.String())
+ }
+ return rec.Body.String()
+ }
+
+ t.Run("sitemap index", func(t *testing.T) {
+ index := get("/sitemap.xml")
+ if !strings.Contains(index, "<sitemapindex") {
+ t.Fatalf("/sitemap.xml is not a sitemap index:\n%s", index)
+ }
+ if !strings.Contains(index, "<loc>"+demoSite+"/sitemap-posts-1.xml</loc>") {
+ t.Fatalf("/sitemap.xml does not list %s/sitemap-posts-1.xml:\n%s", demoSite, index)
+ }
+ posts := get("/_sitemap/files/sitemap-posts-1.xml")
+ if !strings.Contains(posts, "<urlset") || !strings.Contains(posts, "<loc>"+demoSite+"/blog/welcome-to-summercms</loc>") {
+ t.Fatalf("sitemap-posts-1.xml does not list the seeded post:\n%s", posts)
+ }
+ if strings.Contains(index, "sitemap-tags-") || strings.Contains(posts, "/blog/tag/") {
+ t.Fatalf("the sitemaps name tag addresses although golem15.journal.tag_pages is false:\n%s", index)
+ }
+ var answer struct {
+ Action string `json:"action"`
+ }
+ if err := json.Unmarshal([]byte(get("/_seo/api/v1/resolve?url=/blog/tag/releases")), &answer); err != nil {
+ t.Fatal(err)
+ }
+ if answer.Action != "not_found" {
+ t.Fatalf("/blog/tag/releases resolves %q, want not_found (no tag pages)", answer.Action)
+ }
+ })
+
+ t.Run("site answer", func(t *testing.T) {
+ var site struct {
+ SiteURL string `json:"site_url"`
+ Locales []string `json:"locales"`
+ HTMLSitemap struct {
+ Enabled bool `json:"enabled"`
+ Paths map[string]string `json:"paths"`
+ } `json:"html_sitemap"`
+ }
+ if err := json.Unmarshal([]byte(get("/_seo/api/v1/site")), &site); err != nil {
+ t.Fatal(err)
+ }
+ if site.SiteURL != demoSite || !slices.Contains(site.Locales, "en") || !slices.Contains(site.Locales, "pl") {
+ t.Fatalf("site answer %+v, want %s with en and pl", site, demoSite)
+ }
+ if !site.HTMLSitemap.Enabled || site.HTMLSitemap.Paths["en"] != "/sitemap" {
+ t.Fatalf("html_sitemap %+v, want enabled at /sitemap (vue-seo-layer's default path)", site.HTMLSitemap)
+ }
+ })
+
+ t.Run("post resolve", func(t *testing.T) {
+ var answer struct {
+ Action string `json:"action"`
+ SEO struct {
+ Canonical string `json:"canonical"`
+ } `json:"seo"`
+ JSONLD struct {
+ Graph []map[string]any `json:"@graph"`
+ } `json:"jsonld"`
+ Breadcrumbs []struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ } `json:"breadcrumbs"`
+ }
+ if err := json.Unmarshal([]byte(get("/_seo/api/v1/resolve?url=/blog/welcome-to-summercms")), &answer); err != nil {
+ t.Fatal(err)
+ }
+ if answer.Action != "ok" || answer.SEO.Canonical != demoSite+"/blog/welcome-to-summercms" {
+ t.Fatalf("resolve = %s, canonical %q", answer.Action, answer.SEO.Canonical)
+ }
+ var types []string
+ for _, node := range answer.JSONLD.Graph {
+ if typ, ok := node["@type"].(string); ok {
+ types = append(types, typ)
+ }
+ }
+ for _, want := range []string{"WebPage", "BlogPosting", "BreadcrumbList", "WebSite"} {
+ if !slices.Contains(types, want) {
+ t.Fatalf("@graph types %v lack %s", types, want)
+ }
+ }
+ crumbs := answer.Breadcrumbs
+ if len(crumbs) < 3 || crumbs[0].Path != "/" || crumbs[len(crumbs)-1].Path != "/blog/welcome-to-summercms" ||
+ !strings.HasPrefix(crumbs[1].Path, "/blog/category/") {
+ t.Fatalf("breadcrumbs %+v, want home, the category and the post", crumbs)
+ }
+ })
+
+ t.Run("route report", func(t *testing.T) {
+ body := `{"routes": ["/", "/faq", "/contact"]}`
+ if rec := do(http.MethodPost, "/_seo/api/v1/registry/routes", body, map[string]string{"Content-Type": "application/json"}); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("route report without a token = %d, want 401", rec.Code)
+ }
+ rec := do(http.MethodPost, "/_seo/api/v1/registry/routes", body, map[string]string{
+ "Content-Type": "application/json", "Authorization": "Bearer " + reportToken,
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("route report = %d %s", rec.Code, rec.Body.String())
+ }
+ var urls []string
+ if err := demo.DB.Raw(`SELECT url FROM golem15_seo_pages WHERE reported_at IS NOT NULL ORDER BY url`).Scan(&urls).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(urls, "/faq") || !slices.Contains(urls, "/contact") {
+ t.Fatalf("reported pages %v, want /faq and /contact", urls)
+ }
+ })
+}

Some files were not shown because too many files have changed in this diff Show More