Compare commits

...

433 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
15b0d0d148 docs(32-06): complete the validation map
Every task of plans 32-01 to 32-06 with its automated command and the
latest status, Wave 0 checked, nyquist_compliant and wave_0_complete
true; status stays draft until the full gate is green with the demo
mounted. Records the host-only TestNoTideJournalFixtures exception.
2026-10-27 13:36:27 +01:00
8c961f5fb5 test(32-06): framework unit matrices for form extensions, record events and descriptors
- form extensions: placement across three plugins, required plugins' tabs
  and fields, Hide with neighbours and whole tabs, spans, grouping only of
  extended forms; every remaining boot error with plugin, controller and
  offending name
- record events: the pure snapshot/diff (create, update, pointers, times as
  instants, timestamps and key omitted, per-locale translations) and the
  value helpers
- descriptors: MatchPattern edge cases, MatchPath ordering, types without
  Content, duplicate Model/Controller across plugins
2026-10-27 13:11:28 +01:00
2b300ec03d test(32-06): additive and compat stages in check-phase32
- scripts/apisurface: standard-library printer of a package's exported API
  (signatures without parameter names, struct fields and tags, interface
  method sets, constants with their group index), canonical across
  formatting and comment edits
- additive: compares the API of pact, cabana, signpost and every other
  changed package with the newest tag; refuses removed or changed lines and
  methods added to existing interfaces (D-106)
- compat: sitemap at HEAD, Journal and the demo at their pre-phase commits
  (demo with its pinned submodules) build and pass short tests; every other
  sibling that requires the framework builds, each through a scratch go.work
  in a scratch sibling layout; read-only snapshots for other users' repos
- self-test plants 12 API change cases; PHASE32_SKIP_TESTS for host-only
  test failures
2026-10-27 13:08:49 +01:00
49502e8ccb docs(32-05): mark plan 32-05 executed in the roadmap 2026-10-27 12:51:09 +01:00
7353e08c36 docs(32-05): complete SEO management over MCP, batches, undo and CSV plan 2026-10-27 12:50:25 +01:00
20d02d722a docs(32-05): sm-demo-app mount patch for golem15.seo
- proven in a scratch worktree of sm-demo-app 8fcdbdd: TestBootAllPlugins,
  TestSEOTabMounted and TestSEOToolsCatalog pass
- summer.yaml, config/golem15.seo.yaml, the nginx /robots.txt location,
  DEPLOY.md, boot_test.go, plugins.gen.go and the two SEO tests; plan
  32-06 applies it after the plugin tag
2026-10-27 12:48:31 +01:00
9247761c94 test(32-05): check-phase32 gate
- stages self-test, go, plugins, consumers, docker, admin, docs and
  marker; --all with the docker NOT RUN rule (PHASE32_REQUIRE_DOCKER)
- consumers: NOT MOUNTED golem15.seo refuses unless
  PHASE32_ALLOW_UNMOUNTED=1, and the Journal route-drift check between
  the demo's menu_items.yaml and its Nuxt routes
- marker (D-90): lines added since PHASE32_BASE under modules/ and docs/
  name neither SEO nor a consuming application; Journal imports no SEO
  or sitemap plugin; the framework does not require grobotstxt
2026-10-27 12:48:23 +01:00
f96d7d4b4a docs(32-04): mark plan 32-04 executed in the roadmap 2026-10-27 11:57:26 +01:00
8800137d16 docs(32-04): complete redirects, 404 log and robots.txt plan 2026-10-27 11:56:20 +01:00
99889f0bcf docs(32-04): grobotstxt decision note
- github.com/jimsmart/grobotstxt v1.0.3 for the robots.txt tester and
  the noindex-blocked warning in sm-seo-plugin (D-99), plugin-only
- STACK.md Supporting Libraries row
2026-10-27 11:42:51 +01:00
930d91a3c1 docs(32-03): complete SEO plugin core plan 2026-10-27 11:09:44 +01:00
1d3fa32616 docs(32-02): mark plan 32-02 executed in the roadmap 2026-10-26 22:50:57 +01:00
9e3fcebb42 docs(32-02): complete record events and content descriptors plan 2026-10-26 22:50:17 +01:00
785902c612 feat(32-02): signpost content lookups, Records and MatchPath
- ContentType, ContentTypeForController, Records and MatchPath on the
  registry; the pure MatchPattern for whole-segment placeholder matching
- two types declaring the same Content.Model or Controller stop the boot
- TestContent* cover lookups, optional Records, boot errors, MatchPattern
  and MatchPath precedence (first exact, else first inexact)
- docs: Content descriptors in menu-item-types.md, signpost README
2026-10-26 22:39:35 +01:00
ff62906301 feat(32-02): record events from every admin write path
- Delete and each bulk-delete row fire RecordDeleted after commit
- tree reorder fires RecordSaved per moved record (parent/sort in model types)
- relation-manager child create/update/delete fire events naming the
  controller that edits the child's model
- TestRecordEvents covers create, update, translated slug, no-op, rollback
  (validation, FormBeforeUpdate, extension Save), failing listener, reorder,
  child writes, delete and bulk delete; extension Save sees the changes
- docs: Record events in extending.md, admin-controllers notes, cabana README
2026-10-26 22:35:03 +01:00
753fb5969a feat(32-02): record events and content descriptors (tracer)
- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit
- pact.ContentDescriptor and request/result types; MenuItemType.Content
- AdminFormSave.Changes carries the owner's changes to extension Save
- cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and
  translated values and fires RecordSaved through lagoon.AfterCommit
- signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
2026-10-26 22:22:25 +01:00
1b0421de81 docs(32-01): mark plan 32-01 executed in the roadmap 2026-10-26 22:09:06 +01:00
bcbf1aa184 docs(32-01): complete cross-plugin admin form extensions plan 2026-10-26 22:08:24 +01:00
9ae326908f test(32-01): FormView renders compiled extension order
- an extension field between two owner fields renders in the order
  cabana sends (UI-SPEC C1)
- a tab-less form with an extension tab renders the default tab, then
  the new one; admin/src is unchanged
2026-10-26 22:05:22 +01:00
fa1af69376 docs(32-01): form extensions in admin-controllers, cabana and pact READMEs
- admin-controllers: placement, values, Load/Save, permissions,
  widgets, partials and boot errors, with the ExtrasPlugin snippet
- Example_formExtension compiles an owner form with the extension
- coming-from-wintercms maps extendFormFields, addFields, addTabFields
  and removeField to the form extension
- cabana README feature bullet and Activate row
2026-10-26 22:05:22 +01:00
e7423bd21f feat(32-01): form extension placement rules, boot errors and owner-aware branches
- placement: tabs before/after tab anchors, anchor cursor for plugin
  order, own-field anchors sorted with cycle detection, Requires() rule,
  anchor tab inheritance, Hide with reference checks, zone refusal (D-101)
- boot errors name plugin, controller and offending name; fileupload is
  refused (D-100); localized tab label collisions checked in Activate
- extension widgets dispatch to AdminFormExtension.Actions, partials
  render from the contributor's Dir through PartialData, plugin field
  types decode for Save and load after Load, translatable values reach
  Save as locale maps and never the owner's translations
- Permissions and Visible apply to schema, show, save, widgets and
  partials; AdminFormErrors answer 422, Save notices join meta.notices
2026-10-26 22:00:51 +01:00
4bb22a85dd test(32-01): add failing tests for form extension placement and save
- TestFormExtensionPlacement, TestFormExtensionBootErrors,
  TestFormExtensionTwoPluginsSameAnchor, TestFormExtensionOnePluginTwoControllers,
  TestFormExtensionUnextendedSchemaUnchanged, TestFormExtensionWidgetsAndPartials
- TestFormExtensionSave (Postgres): rollback, 422 lines, notices,
  invisible extensions, locale maps, hidden owner fields
- checkFormExtensionTabLabels declared (no-op) so the tests compile
2026-10-26 21:51:35 +01:00
0819442634 feat(32-01): cross-plugin admin form extensions (tracer)
- pact.AdminActionExtension.Form with AdminFormExtension: a fields
  document (fields.yaml format plus before:/after:/zone: and a tabs:
  list) given as an AdminFS path or YAML bytes, Load and Save hooks,
  AdminFormLoad/Save/SaveResult, AdminFormErrors and zone constants
- cabana compileFormExtensions places extension fields at owner
  anchors at boot, regrouping an extended form by tab
- save lifts extension values before any owner path and runs Save in
  the record's transaction after the owner row; show merges Load
- TestFormExtensionTracer: c after a on a, b gives a, c, b through the
  admin API, stored in the contributor's table
2026-10-26 21:46:43 +01:00
9868838fe3 docs(32): annotate roadmap with plan waves 2026-10-26 21:01:47 +01:00
641befc933 docs(32): revise plans after check 2026-10-26 21:00:14 +01:00
5d7e3caaea docs(32): create phase plan
Six plans per D-98: form extensions, record events and content
descriptors with Journal adoption, sm-seo-plugin core, redirects and
robots.txt, management API with MCP, batch, undo and CSV, then unit
tests and the patch release. COVERAGE.md declares no external API
integration.
2026-10-26 20:41:44 +01:00
748574c7cb docs(32): record D-106 patch release and repo creation 2026-10-26 20:11:38 +01:00
e3c0de24a6 docs(32): record planning decisions D-98 to D-105 2026-10-26 20:08:20 +01:00
62bcecf704 docs(32): pattern map 2026-10-26 20:07:10 +01:00
e0cf189ad8 docs(phase-32): add validation strategy 2026-10-26 20:04:32 +01:00
54d770740c docs(32): research phase domain 2026-10-26 20:03:37 +01:00
d661e1de95 docs(32): UI design contract
Admin surfaces for sm-seo-plugin: SEO tab, six custom elements, redirects, 404 log, URL rules, robots.txt, history, CSV import/export. Keyphrase stem matching decided (Q-4).
2026-10-26 19:45:16 +01:00
28b6b55f29 test(19.6): refresh verification and persist human verification items as UAT 2026-10-26 17:06:02 +01:00
d5aed6bb14 docs(19.6): phase verification report (human_needed) 2026-10-26 17:03:15 +01:00
bc8f9540a9 test(19.6): gate requires the code review regression tests 2026-10-26 17:03:15 +01:00
1769e15fbd docs(19.6): record code review disposition and the WR-05 todo 2026-10-26 16:43:41 +01:00
3cc5de056f docs(19.6): add code review fix report 2026-10-26 16:34:12 +01:00
680d4021e4 docs(19.6): add code review report 2026-10-26 15:45:43 +01:00
a70dbb6ff0 docs(todos): link two 19.6 todos to their g15office tasks 2026-10-26 15:36:59 +01:00
ac0599e78d docs(19.6-04): mark plan 04 complete in the roadmap 2026-10-26 15:35:09 +01:00
4925dddd40 docs(19.6-04): complete unit tests and security review plan 2026-10-26 15:34:38 +01:00
07f4632dc2 docs(19.6): v0.1.1 release recorded, finding F-1 deferred
- security review: T-19.6-37 closed by sm-mfa-plugin v0.1.1, F-1 deferred
  with the corrected reason for the activate-by-code mint path
- validation map: plan 04 Tasks 5 and 6 green
- todo for F-1; deferred items: tag message resolved, F-1 deferred
2026-10-26 15:34:38 +01:00
12b60d0194 docs(19.6-04): draft summary at the Task 5 release checkpoint 2026-10-26 15:06:45 +01:00
692beb5538 docs(19.6): security review and validation map
- 19.6-SECURITY-REVIEW.md: T-19.6-01..38 mapped to their tests or accepted
  dispositions, the D-14 passkey section, D-18 hardening, ASVS notes, log
  sweep, residual risks and open finding F-1 (activate-by-code)
- 19.6-VALIDATION.md: validated, one row per task of plans 01-04 with its
  gate stage and tests
2026-10-26 15:00:05 +01:00
ea4680eebe test(19.6): gate requires the PHP map
The mfa stage requires TestPHPMethodMap and TestPHPUser005 by name.
2026-10-26 14:54:08 +01:00
531e7abae7 test(19.6): gate requires the mfa and social gate tests
- mfa stage: the seven Docker-free decision tests by name
- docker stage: the sixteen Postgres decision tests of sm-mfa-plugin,
  TestEveryLoginPathIsGated included, and TestSocialGate
2026-10-26 14:51:38 +01:00
e9785f71de test(19.6): gate requires the user-plugin unit tests
- userplugin stage: TestLoginGates (also under -race), TestDeviceAuthURL,
  TestDeviceDisplayName and TestDeviceCodes in classes
- docker stage: the gate order, password-change sources, reset action and
  device-flow tests, and TestDeviceAuthSessionsMigration in updates
2026-10-26 14:19:01 +01:00
947009a62d test(19.6): fail-closed phase gate
- stages go, userplugin, mfa, sociallogin, demo, demo-web, bm, fonoteka,
  docker and docs across the seven repositories the phase touched
- go test -json detector refuses fail, skip, zero tests and missing names;
  --self-test proves it, the additive and migration checks and the docker
  NOT RUN rule on planted input
2026-10-26 13:58:35 +01:00
e95277a210 docs(19.6-03): mark plan 03 complete in the roadmap 2026-10-26 13:47:53 +01:00
d3b1636ac7 docs(19.6-03): complete WebAuthn and adoption plan
sm-demo-app mounts golem15.mfa v0.1.0 at user v0.1.12, sociallogin v0.1.4
and the two-factor UI (0eb1f60..bb24207, pushed with the 19 earlier
commits as 18597e5..bb24207, not deployed). The summary records the
"proceed" reply, the push checks and the tests. deferred-items.md gains the
private sm-mfa-plugin repository's effect on the demo's release build, and
a pending todo tracks the demo's leftover journal and oauthserver moves.
2026-10-26 13:47:45 +01:00
4002beafb6 docs(19.6-03): release and adoption up to the sm-demo-app checkpoint 2026-10-26 13:24:17 +01:00
5a6461123c docs(19.6-03): draft summary at the Task 4 release checkpoint
- WebAuthn and passkeys committed in sm-mfa-plugin (2662252, 6c8916f),
  demo two-factor UI in vue-demo-app (44a5229)
- sociallogin D-09 change kept as a patch: the checkout is jin's
- deferred items: sociallogin gofmt finding, no demo type check
2026-10-26 12:49:36 +01:00
5d78f53ddc docs(19.6-02): mark plan 02 complete in the roadmap 2026-10-26 11:22:11 +01:00
eb21d1f6e9 docs(19.6-02): complete sm-mfa-plugin core plan summary 2026-10-26 11:21:13 +01:00
33920ed558 docs(19.6-01): mark plan 01 complete in the roadmap 2026-10-26 10:50:20 +01:00
07c4e7f59e docs(19.6-01): complete foundations plan summary 2026-10-26 10:49:55 +01:00
24d9d2a401 docs(19.6): decide the OTP, WebAuthn and QR libraries
pquerna/otp v1.5.0, go-webauthn/webauthn v0.18.2 and boombuler/barcode
v1.1.0 (QR, with the plugin's own SVG writer), imported only by
sm-mfa-plugin and gated by a blocking human package check.
2026-10-26 09:49:40 +01:00
ecc34bc35a docs: narrow phase 32 and add phase 33 SEO output, audit and content 2026-10-25 22:26:43 +01:00
9a76c7ec1c docs(32): split SEO into phase 32 and a large phase 33 2026-10-25 22:25:33 +01:00
c5fee4c63a docs(32): set SEO code layout, resolve phase split, confirm D-32
SEO moves to sm-seo-plugin and sm-sitemap-plugin on three generic
framework seams (form extensions with field placement, record events,
signpost content descriptors), following the mpseo pattern. Phase 32 is
split into 32/33/34.
2026-10-25 19:49:19 +01:00
723c27e352 docs(state): record phase 32 context session 2026-10-24 16:47:38 +02:00
7fd23f0642 docs(32): capture phase context 2026-10-24 16:47:37 +02:00
6bc7b5cb20 docs: g15office task for phase 32 2026-10-24 16:07:18 +02:00
07e32b5be8 docs: add phase 32 SEO foundation to the roadmap 2026-10-24 16:06:53 +02:00
223f87e0f1 docs(phase-19.7): update validation strategy 2026-10-24 00:39:40 +02:00
49a3df68c8 test(phase-19.7): add Nyquist validation tests 2026-10-24 00:38:42 +02:00
505b8991ee docs(19.7): verification passed after UAT 2026-10-24 00:31:40 +02:00
691459174a test(19.7): complete UAT - 5 passed, 0 issues 2026-10-24 00:31:27 +02:00
eb9a62a5ec test(19.7): accept the SC-3 group-grant override in UAT 2026-10-24 00:17:04 +02:00
b8e7ba9d4f docs(19.7): keep coverage matrix rows within the gate's length limit 2026-10-24 00:16:28 +02:00
1038034078 docs(19.7): add MCP API coverage matrix 2026-10-24 00:16:16 +02:00
4c20459d03 docs(phase-19.7): record verification pause for UAT 2026-10-23 22:05:29 +02:00
85a137df24 test(19.7): persist human verification items as UAT 2026-10-23 22:05:28 +02:00
94ee2cee03 docs(19.7): phase verification (human_needed) 2026-10-23 22:05:22 +02:00
33c40ff483 docs(19.7): add re-review fix report 2026-10-23 21:29:07 +02:00
1fe553e344 docs(19.7): record the journal plugin head after the lane C fixes 2026-10-23 21:27:36 +02:00
4d5a09ae62 docs(19.7): defer the remaining lane C re-review items 2026-10-23 21:17:46 +02:00
f824d9354c test(19.7): the gate requires TestMCPBackendBinding and TestAvatarURL
The docker stage now runs the oauthserver plugin's consented-administrator
test (RC-WR-01) and the user plugin's avatar URL test (RC-IN-03).
2026-10-23 21:17:08 +02:00
c37c6ce50f fix(19.7): RC-WR-01 a backend grant acts only as the administrator it was consented for
pact.MCPGrant gains BackendUserID, the backend account the consent page
showed. pier gives an OAuth identity a backend subject (on resolution and
after a link event) only while the user's link names that account; a link
to anyone else, or no recorded account, gives none, so a link changed after
the consent never re-targets the grant.
2026-10-23 20:51:57 +02:00
0b0cae5b53 docs(19.7): defer the remaining lane B re-review items 2026-10-23 20:39:37 +02:00
34dce1ca78 fix(19.7): rebuild the embedded admin for the lane B re-review fixes
modules/boardwalk/dist from a fresh admin build after RB-WR-01,
RB-IN-01 and RB-IN-04; check-admin-dist.sh reports a match.
2026-10-23 20:39:10 +02:00
89b85f58d5 fix(19.7): RB-IN-04 document that a focused widget host mutes the plain keys
The custom-element-in-form rule that keeps Esc, / and ? out of a plugin
field type's closed shadow root also applies to a focusable widget that
is not an input. The header comment and isEditableTarget now state the
trade-off and why the rule is not narrowed to plugin field types.
2026-10-23 20:37:30 +02:00
4d7eb9dcfb docs(19.7): RB-IN-03 only the My account revoke fires the token event
The README said a revoked personal API token fires the backend subject
event. admin:token:revoke runs in its own process, where no MCP endpoint
listens, so it fires none; its token's sessions end at the next
verification. Say so in the events entry and the command table.
2026-10-23 20:36:11 +02:00
81342f1d9b fix(19.7): RB-IN-02 a failing Applies marks its My account section
An action whose Applies failed was dropped with no signal, so a section
whose Status succeeded showed a normal card with a button silently
missing. The section is now also sent with error: true, and My account
shows its error line. The error field's description, the cabana README
and the My account docs say so; the OpenAPI document and the SPA schema
types are regenerated.
2026-10-23 20:34:44 +02:00
633d45082e fix(19.7): RB-IN-01 the My account URL check refuses control characters
Browsers strip tab and newline from URLs, so "/\t/host" passed the
SPA's same-origin check and resolved off-site. The check now refuses any
ASCII control character first, as Go's url.Parse does on the server, and
an absolute URL must parse with a host, so both sides apply one rule.
2026-10-23 20:31:28 +02:00
a5ce43f211 fix(19.7): RB-WR-02 a padded token scope name fails boot
validatePluginTokenScope checked the trimmed name with
pact.ValidTokenScopeName while compileTokenScopes registered the
untrimmed one, so " blog:read" booted, was listed under its padded name
and could never be granted, and the duplicate check depended on
registration order. The name is now validated exactly as registered,
which is the shared rule pact.ValidTokenScopeName states.
2026-10-23 20:29:30 +02:00
3afe9fa19c fix(19.7): RB-WR-01 save shortcuts commit a draft inside a field type's shadow root
The save shortcuts blurred document.activeElement before clicking. With
focus on an input inside a shadow root that is the host, and blur() on a
host that is not itself focused does nothing (spec, Firefox), so a plugin
field type committing on the native change event lost the typed value.
The shortcut now focuses the button first, as a pointer press does, which
unfocuses the inner input in open and closed roots, and gives focus back
to the innermost element reached through open shadow roots.
2026-10-23 20:27:47 +02:00
eea04b171e docs(19.7): defer the remaining lane A re-review items 2026-10-23 20:23:17 +02:00
034aa19904 fix(19.7): RA-IN-04 the non-reserving store fallback writes the record first
For a store without Reserve (only a test wrapper reaches this; both shipped
stores reserve), serveNew now calls Put right after the Count check and
before the server is built, and a failed connect or initialize deletes the
record again. The session is therefore never in the local map without its
record, so a reaper pass in between cannot close it as expired. The cap of
this fallback stays check-then-act, as its comment now says.
2026-10-23 20:22:58 +02:00
c0ce07ecb7 fix(19.7): RA-IN-05 a token whose scopes no longer resolve gets its own error
The built-in backend guard's principal answers errScopeUnavailable, not a
nil principal, when an admin personal token's stored scopes all fail to
resolve (a scope's plugin was removed). MCPDispatch passes it through and
dispatch maps it to the fixed tool error "pier: the token's scopes no longer
grant this route" instead of claiming the administrator's account is
unknown or deactivated; the listing check still treats it as a refusal, not
as a failed check.
2026-10-23 20:21:05 +02:00
afc7510d1b fix(19.7): RA-IN-02 stream bearer re-checks carry the client address
The stateful GET stream and the stateless subscriptions/listen stream
capture the client address when they open and resolve every periodic
re-check with towel.WithClientIP, as the bearer middleware does, so a
failing re-check (a revoked admin token, for example) is logged with the
remote address. TestStreamRecheckClientIP records the address the verifier
sees on every verification of both streams.
2026-10-23 20:19:22 +02:00
58408aaa83 fix(19.7): RA-IN-03 document that the Redis session store needs a standalone Redis
reserveScript reads the record keys of the binding set's members, which it
cannot declare in KEYS, and its declared keys hash to different slots, so
Redis Cluster refuses it and a key-pattern ACL must allow the whole
<prefix>mcp: range. The script's comment, the pier README's
mcp.sessions.store row (which also no longer says the store opens on the
first request) and docs/services/mcp.md say so.
2026-10-23 20:17:17 +02:00
38e028fbe6 fix(19.7): RA-IN-01 describe the Redis store's mount-time warm-up correctly
lazyStore's and newStore's doc comments no longer claim that nothing dials
at mount: Mount starts a background warm-up that dials Redis with a backoff
and logs each failed attempt. The comment records the decision to keep the
warm-up for commands that mount pier without serving, such as route:list:
it only dials and logs until the process exits.
2026-10-23 20:16:51 +02:00
99f5ed4dfb fix(19.7): RA-IN-06 demo_pins refuses a failing pin check with no mismatch line
demo_pins counts the mismatch lines it accepts and refuses when
check-pins.sh exits non-zero without one, instead of passing on the single
empty line of an empty output. The self-test plants a silent failing
check-pins.sh (pins-silent-failure).
2026-10-23 20:16:32 +02:00
2fdda99c08 fix(19.7): RA-WR-06 qualify the gate verdict while demo pins are unreleased
demo_pins records every plugin submodule it accepts ahead of its go.mod pin
by non-test commits (UNRELEASED_PINS, and PHASE197_PINS_FILE for run_all).
The consumers stage then ends with "phase19.7 consumers passed WITH
UNRELEASED PINS: <names> (the release pins are not yet proven)", and --all
ends with a notice plus "Phase 19.7 gate passed WITH UNRELEASED PINS: the
release pins are not yet proven (<names>)" instead of the unqualified
"Phase 19.7 gate passed". The exit status stays 0 so the pre-release
verification can run; only a PHASE197_RELEASED=1 run proves the pins.

The self-test pins that accepted unreleased drift is recorded, test-only
drift is not, and gate_verdict is unqualified only with no recorded pin.
2026-10-23 20:15:50 +02:00
788d4a49fa fix(19.7): RA-WR-05 keep prompt Render errors out of the client's answer
promptHandler logs a failing Render's error with the prompt name and
answers the fixed JSON-RPC internal error "pier: the prompt could not be
rendered", as dispatch does for tool calls (A-WR-08), so database or
provider text never reaches the agent or its transcript.

TestPromptRenderError renders a prompt that fails with a database error
naming its host and asserts, on both paths, that the client sees only the
fixed text and the cause is in the log.
2026-10-23 20:12:22 +02:00
928731db4d fix(19.7): RA-WR-04 LRU eviction spares stateless servers that are serving
Each stateless request now carries an entry-use record on its context;
getServer counts the entry it hands out as active (under the path lock)
and ServeHTTP releases it when the request ends, so a subscriptions/listen
stream keeps its entry active for its whole life. Capacity eviction walks
the LRU from the back and drops only idle entries, so it no longer ends a
quiet listener or cancels a tool call in flight, and a revocation still
finds every open stream. While every entry is busy the LRU stays above
capacity until requests end.

TestStatelessFanOut's LRU subtest now asserts that the listener's server is
spared when the binding past capacity arrives (the oldest idle server goes
instead) and that a revocation then still ends the stream.
2026-10-23 20:10:36 +02:00
e7630e6762 fix(19.7): RA-WR-03 a guard error while re-filtering no longer narrows a session
catalog.allowedReport returns the allowed lists together with the first
provider error. Widening (stateful) and the stateless refresh leave the
cached server and its recorded fingerprint untouched on an error, so the
next request retries instead of the tools staying hidden until the next
rotation or event. A fan-out recompute still applies its fail-closed diff
but records no fingerprint, so the binding's next request re-filters.

TestWideningGuardError fails the guard during a widening on both paths and
during a permissions recompute, then asserts that the next request lists
every tool once the provider answers again.
2026-10-23 20:07:51 +02:00
f604e6de64 fix(19.7): RA-WR-02 stop a shared stateless server flapping its tool set
The identity fingerprint no longer includes token ids: guards decide on
scopes, subjects and the ceiling, and an OAuth access token's id changes on
every refresh, so a rotation alone no longer re-filters a cached server.

The stateless path's refresh only adds tools and prompts for a fingerprint
new to the entry (remembered until a fan-out recompute) and never removes:
one server serves every live token of a binding, and removing on each
alternation between a wider and a narrower token pulled a tool from under
the wider token's call. The receiving middleware still filters every list
and call per request; fan-out recomputes still narrow. The stateful path
keeps its exact widen, a session serves one client.

TestFingerprintIgnoresTokenID pins the fingerprint; TestStatelessSharedBinding
blocks the wide token's call after its server was chosen, sends a request
of the narrow token and asserts the call still succeeds (it failed with
"unknown tool" before).
2026-10-23 20:05:08 +02:00
cccf4025ea fix(19.7): RA-WR-01 never wait on a session lock under the router lock
Fan-out matching snapshots the sessions (and stateless entries) under the
router-wide lock, releases it, and reads each identity through a small
identity box that is never held across I/O, so an event no longer waits on
a session that is re-filtering while it holds sr.mu or sp.mu.

Widening and the fan-out recomputes run their guard checks with
guardContext: not cancelled with the triggering request, but bounded by
mcp.call_timeout, so a provider that never answers cannot hold a session
lock forever. README and docs/services/mcp.md describe the wider meaning of
mcp.call_timeout.

TestLockScopeUnderWidening blocks a widening guard check, fires a
permission event for that session and asserts that another session's call
still finishes, and that a never-answering check ends at call_timeout.
2026-10-23 20:02:13 +02:00
f53668a3e8 docs(19.7): add gap-closure re-review report 2026-10-23 19:21:58 +02:00
d6a1297a5f docs(phase-19.7): update tracking after gap wave 4 2026-10-23 19:05:56 +02:00
883ac5133c docs(19.7-09): complete the gap-closure tests, gate and security review plan 2026-10-23 19:03:06 +02:00
e1f489058c docs(19.7-09): deferred items for the gap closure
B-IN-05 (deferred, the shared confirm primitive), B-IN-06 (no fix
needed), the admin:token:revoke event, the OIDC decision (resolved), the
local go.work retarget, the demo pins, the release order with
PHASE197_RELEASED, the lenient link code parse and one offline-build
flake; the plan 05 test-commit entry is superseded by the release.
2026-10-23 18:55:37 +02:00
a636933299 docs(19.7-09): validation rows for every gap-closure test
One row per new behaviour of plans 06-09 with its final command; every row
green after the plan 09 gate run; nyquist_compliant stays true.
2026-10-23 18:54:51 +02:00
33faa44e53 docs(19.7-09): security contract with the gap-closure threats and corrected rows
T-19.7-42..T-19.7-65 join the register, the eight corrected rows carry
their fix commits, the audit trail records the gap closure; threats_open
stays 0 (every high threat is fixed or verified).
2026-10-23 18:54:06 +02:00
946fb2d3ea docs(19.7-09): security review corrected and extended for the gap closure
Corrects the verdicts the code review disproved (T-19.7-22 first, then
T-19.7-03, -08, -17, -18, -23, -31, -35 and cross-cutting findings 1 and
6), adds the Method step that a high or critical control is verified only
by a test that fails without it, the revert-checks of the three critical
fixes, and the register rows T-19.7-42..T-19.7-65.
2026-10-23 18:53:31 +02:00
7f43e0eff0 test(19.7-09): the gate requires the plan 08 plugin, site and demo tests
- USER_SHORT_TESTS (TestMemberTokenScopesBoot, GOWORK=off -short) in the
  plugins stage; USER_TESTS, OAS_TESTS, JOURNAL_SHORT_TESTS and DEMO_TESTS
  gain the gap-closure tests and the plan 08 smokes
- consumers stage: the demo site's vitest run with VUE_FILES
  (app/lib/linkCode.test.ts) required, through detect_vitest
- docs stage: cooler's KeyPrefix and keyPrefix carry their own doc
  comments (A-IN-02)
- self-test cases for the demo site's required file
2026-10-23 18:50:22 +02:00
c2d486bf0e test(19.7-09): TestSessionReaper requires the reaper only where nothing else can close the copy
A request's state is persisted after its answer is streamed; one of the
instance that served the last request may finish after the expiry and
close that copy itself (the record is gone). The reaper is required to
close the copy of the instance that did not serve it.
2026-10-23 18:44:03 +02:00
ccf77b9103 test(19.7-09): named tests for every framework and admin SPA fix of plans 06 and 07
- pier: TestSessionReserve (A-WR-03), TestLockScope (A-WR-07),
  TestScopeWidening (A-WR-04), TestStatelessFanOut (A-WR-05, A-WR-06),
  TestPersonalTokenBinding (A-WR-09), TestDispatchHardening (A-WR-01,
  A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-08), TestCatalogPathFields
  (A-WR-02, A-IN-07), TestPromptWriteCeiling (C-IN-01),
  TestTokenScopedRevoke (B-WR-04, C-IN-02), TestResolveClientAddress
  (B-IN-04); test seam WrapStore
- pact TestFrontendTokenScopes, towel TestClientIP, cabana
  TestTokenScopeNameParity
- cabana TestAccountContributionURL (B-WR-03), TestAccountContributionsIsolation
  (B-WR-06, B-IN-03), TestRecordActionPromptLocalized (B-IN-02), and the
  Postgres TestAccountTokenRevokeEvent (B-WR-04) and
  TestResolveBackendTokenRemote (B-IN-04)
- vitest: hotkeysEditing.test.ts (B-WR-01, B-WR-02) and the extended
  AccountContributions.test.ts (B-WR-03, B-WR-06, B-IN-01)
- check-phase19.7.sh requires every name (GO_TESTS, GO_TAGGED_TESTS,
  DOCKER_TESTS, ADMIN_FILES) and scans the new files (PHASE_TEST_FILES)
2026-10-23 18:36:16 +02:00
fda46466c7 test(19.7-09): TestSessionReaper tolerates a transient store read on a loaded host
A reaper pass that cannot read the store keeps the copy by design, so the
Redis case reaps until the copy is gone and still requires exactly one
close per instance; both copies are asserted before the expiry.
2026-10-23 18:34:46 +02:00
0aa48ae6c4 test(19.7-09): gate workspace check and unreleased-pin policy, TestSessionReaper and TestAuthTime
- check-phase19.7.sh: workspace_check (framework to this checkout, the OAuth
  server plugin's user plugin to USER_PLUGIN) in the plugins, consumers and
  docker stages; consumer_with_user_plugin builds fonoteka.go and sm-bm-app
  against USER_PLUGIN through a scratch go.work; demo_pins accepts
  unreleased commits its canonical checkout holds and refuses any drift
  with PHASE197_RELEASED=1; self-test cases for each, one PASS line each
- TestSessionReaper pins A-CR-01, A-IN-01 and A-IN-06 (memory and Redis
  stores, store errors, the GET stream re-check, the ephemeral label)
- TestAuthTime pins auth_time at mint and through every refresh path
- GO_TESTS and GO_TAGGED_TESTS require them with the plan 06/07 smokes
2026-10-23 17:45:51 +02:00
05d7e9351a docs(phase-19.7): resume session before 19.7-09, retire handoff 2026-10-23 17:22:05 +02:00
Jakub Zych
bccf06d960 wip: phase 19.7 paused at gap plan 8/9 (before 19.7-09) 2026-10-23 17:14:30 +02:00
Jakub Zych
fdf7efdd5c docs(phase-19.7): update tracking after gap wave 3 2026-10-23 17:13:30 +02:00
Jakub Zych
1e5d9d4f88 docs(19.7-08): complete plugin gap-closure plan (lane C, E-1, E-2) 2026-10-23 17:13:01 +02:00
Jakub Zych
5aeea304d0 docs(phase-19.7): update tracking after gap wave 2 2026-10-23 16:33:11 +02:00
Jakub Zych
d6c7fd5a27 docs(19.7-07): complete lane B gap-closure plan with auth_time and the E-2 seam 2026-10-23 16:32:44 +02:00
Jakub Zych
88559d5731 fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links (B-WR-01, B-WR-02, B-WR-03, B-WR-06, B-IN-01)
- plain-key shortcuts test composedPath()[0], and a custom element inside a
  form counts as editable (closed shadow roots)
- Ctrl/Cmd+S and Ctrl/Cmd+Enter blur the focused field before clicking, so a
  pending taglist draft or native change is committed, then refocus it
- safeUrl keeps javascript:/data: URLs out of href and window.open
- a failed contributions GET shows a toast and keeps the sections; a section
  with error true shows an error line
- My account confirm and stale texts get their own phrase keys
- rebuilt modules/boardwalk/dist
2026-10-23 16:29:10 +02:00
Jakub Zych
44835e4c6b fix(cabana): safe account action URLs, per-section isolation and the prompted record action contract (B-WR-03, B-WR-05, B-WR-06)
- an account action URL is kept only when it is http(s) with a host or a
  same-origin path; anything else is dropped and logged without the URL
- a failing Status marks its section error: true and a failing Applies
  drops only that action, instead of a 500 for the whole list
- status text goes through localizeDetail (B-IN-03); the non-string
  payload refusal is the phrase key action_prompt_invalid (B-IN-02)
- the record action annotations and pact's prompt doc say what happens
  (B-IN-05 text); admin.json and schema.d.ts regenerated
2026-10-23 16:24:34 +02:00
Jakub Zych
24cf6cd931 fix(pier): end only the revoked token's or client's MCP sessions and log the client address (B-WR-04, B-IN-04)
- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a
  narrowed revoked event closes only matching sessions and stateless servers
- cabana fires the admin token revoke with the token row id
- towel.WithClientIP/ClientIP; pier puts the address (surf applies
  http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
2026-10-23 16:21:37 +02:00
Jakub Zych
0ec00f7f69 docs(19.7-07): record the member token scope design decision (E-2, P-30) 2026-10-23 16:15:43 +02:00
Jakub Zych
9eedfbbb29 feat(pact): add HasFrontendTokenScopes and the shared ValidTokenScopeName rule (E-2)
- pact.FrontendTokenScope and pact.HasFrontendTokenScopes declare member
  (usr_) token scopes; the admin token catalog never reads them
- pact.ValidTokenScopeName carries cabana's scope-name pattern; cabana's
  g15_ catalog switches to it with unchanged behaviour and messages
2026-10-23 16:15:25 +02:00
Jakub Zych
c14238b94b feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh (C-CR-02)
- MintAudience stamps auth_time equal to iat; refreshAudience copies the
  presented token's value and never renews it (absent stays absent)
- bouncer.ClaimAuthTime and bouncer.VerifyAuthTime for plugins, with the
  same signature, audience and expiry checks as VerifyClaims
- TestAuthTimeSmoke traces mint, refresh and read
2026-10-23 16:13:49 +02:00
Jakub Zych
60a86677f2 docs(phase-19.7): update tracking after gap wave 1 2026-10-23 16:11:17 +02:00
Jakub Zych
6ca9e1706d docs(19.7-06): complete lane A gap-closure plan in pier 2026-10-23 16:10:52 +02:00
Jakub Zych
95f177b1e0 docs(cooler): give KeyPrefix and keyPrefix their own doc comments (A-IN-02) 2026-10-23 16:08:27 +02:00
Jakub Zych
211cec11b0 fix(pact): add MCPPrompt.Writes so a write-directing prompt sits under the read/write ceiling
- pact.MCPPrompt.Writes (additive; the zero value keeps today's listing)
- pier's allowsPrompt hides a prompt declared with Writes from an identity
  without write, as it hides a tool that is not read-only (C-IN-01
  framework part, P-28)
2026-10-23 16:08:27 +02:00
Jakub Zych
104db9959f fix(pier): refuse path values the mux never delivers and harden dispatch
- a path field must be required in the reflected schema (boot error), and
  a missing value, an empty, . or .. segment, or a / inside one segment is
  a tool error before dispatch (A-WR-01, A-WR-02)
- a failed guard dispatch logs its cause and answers a fixed tool text;
  only an invalid token and an unknown or deactivated account keep their
  own fixed texts (A-WR-08)
- path and query scalars decode with UseNumber, so integers above 2^53
  keep their digits (A-IN-04)
- the dispatched request carries the outer Host, X-Forwarded-Proto and
  X-Forwarded-Host (A-IN-05)
- an embedded struct on a GET or DELETE tool fails boot (A-IN-07)
- a dispatch whose guard list or principal kind contradicts the tool's
  guard is refused (A-IN-08)
2026-10-23 16:05:32 +02:00
Jakub Zych
240457291f fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
2026-10-23 15:59:54 +02:00
Jakub Zych
c1c0c77ddc fix(pier): reserve session slots atomically and build servers outside router locks
- an unexported reserver (Reserve, Release) next to updater: the memory
  store reserves under its mutex, the Redis store in one Lua script
  (prune, SCARD, SET PX plus SADD); serveNew reserves instead of Count
  then Put and releases on a failed connect or initialize (A-WR-03)
- serveNew, lookup's rehydration and the stateless getServer build the
  SDK server with no router-wide lock held and insert or adopt under the
  lock afterwards, so a slow guard or database check no longer stalls
  the instance (A-WR-07)
2026-10-23 15:56:57 +02:00
Jakub Zych
2bf9dca603 fix(pier): reap stateful sessions whose store record expired
- a reaper started at Mount checks every local session against the store
  every min(idle_ttl/2, 1m) and closes each copy whose record is gone,
  logging the expired step (A-CR-01, A-IN-06)
- the GET stream re-check ends the stream when the record is gone (A-IN-01)
- tool calls served without a session on the stateful path log the
  session as ephemeral (A-IN-06)
- TestPierReaperSmoke and the ReapNow, LocalSessions, StopReaper seams
2026-10-23 15:50:04 +02:00
Jakub Zych
d18d8f0dc2 docs(19.7): ban and password reset revoke OIDC grants as well as MCP grants (user decision) 2026-10-23 15:46:44 +02:00
Jakub Zych
d763e92650 docs(19.7): gap-closure plans 06-09 for the review findings and the sm-servers-app escalations 2026-10-23 15:44:15 +02:00
Jakub Zych
2527498d03 docs(19.7): gap-closure plan split and release order 2026-10-23 15:03:52 +02:00
Jakub Zych
cee58c0f65 docs(19.7): gap-closure inputs: review findings and two sm-servers-app escalations 2026-10-23 15:02:44 +02:00
Jakub Zych
fef1cbbbbd docs(19.7): add code review report 2026-10-23 14:54:54 +02:00
Jakub Zych
5563df7e68 docs(phase-19.7): update tracking after wave 5 2026-10-23 14:42:11 +02:00
Jakub Zych
d9d2acae88 docs(19.7-05): plan 05 summary; phase gate green, security review done, plugin test commits local 2026-10-23 14:41:38 +02:00
Jakub Zych
4730e37742 docs(19.7-05): security review and contract, deferred items, folded todos closed
- 19.7-SECURITY-REVIEW.md: T-19.7-01..41 and T-19.7-SC with file, line and
  pinning test; two production fixes (c49983d, 1111828); superuser reach
  accepted as designed with audit and ceiling
- 19.7-SECURITY.md: threats_open 0, accepted risks AR-19.7-01..05
- the frontend permission and account link todos move to todos/done with
  their closing notes
2026-10-23 14:29:56 +02:00
Jakub Zych
0b8e027b81 test(19.7-05): security review pins for the MCP endpoint's outer layers
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
  read, a body over the endpoint cap is 413, a long GET stream ends once
  its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
  refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
  demo's deploy check (T-19.7-37)
2026-10-23 14:26:26 +02:00
Jakub Zych
d621ef1b41 docs(19.7-05): validation map filled, Wave 0 complete, Nyquist compliant 2026-10-23 14:24:13 +02:00
Jakub Zych
a94bdc199d test(19.7-05): gate stages plugins, consumers, docker and docs
- plugins: vet, short tests and the additive-export check of the user
  (GOWORK=off, against 930b158 and v0.1.9), OAuth server (d31f8e7) and blog
  (ffa2c8f) plugins, the blog plugin's MCP tests by name
- consumers: the three applications build and pass at their pins, the demo
  also with the MCP tag; the pin check accepts a plugin submodule ahead of
  its pin only by test-only commits
- docker: the cabana, user plugin, OAuth server and demo Postgres suites by
  name
- docs: decision note and STACK rows, docs checks, no consuming application
  named in sources, docs or the phase's tests
- self-test proves additive_check on a planted repository
2026-10-23 14:24:13 +02:00
Jakub Zych
f14eae96dd test(19.7-05): framework unit tests for every validation row and the gate's admin stage
- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
2026-10-23 14:04:16 +02:00
Jakub Zych
1111828e8a fix(pier): an ended session is never written back by a request still in flight
- persisting a session's protocol state after a request now rewrites an
  existing record only (Redis SET XX, the memory store likewise) and closes
  the local copy when the record is gone
- before, a request that finished after DELETE, a revoked or deleted subject
  event or the idle expiry put the record back, and the next request
  rehydrated the ended session
- found by plan 19.7-05's TestFanOut (revocation and deletion cases, about
  one run in a hundred)
2026-10-23 13:55:04 +02:00
Jakub Zych
c29d2289d0 fix(pier): a tools/call without arguments or with null arguments is an empty object
- MCP's tools/call arguments are optional; pier validated a JSON null
  against the input schema and refused every call of a tool without
  required input that an agent sent without arguments
- found by plan 19.7-05's TestDispatch
2026-10-23 13:33:24 +02:00
Jakub Zych
3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go,
  marker; plugins, consumers, docker, admin and docs are stubs until the
  later tasks fill them)
- modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes
  plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver,
  routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged
- TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres
  part through the screens harness, which now keeps its app)
2026-10-23 13:31:41 +02:00
Jakub Zych
c49983d3f8 fix(cabana): the backend middleware refuses a dispatch marker carrying a frontend principal
- cabana wraps the bouncer backend guard middleware: a marker that lists the
  backend guard with Backend false answers 401 before the guard honours it,
  as ResolveBackend already does, so a frontend user id is never read as an
  administrator's by the admin API or a backend-guarded plugin route
- found by plan 19.7-05's TestDispatchMarker (admin API in-process case)
2026-10-23 13:27:25 +02:00
Jakub Zych
905111ba72 docs(phase-19.7): update tracking after wave 4 2026-10-23 13:18:15 +02:00
Jakub Zych
81266de77d docs(19.7-04): plan 04 summary; oauthserver v0.1.2 and Journal v0.2.3 released, pinned in the demo
- SUMMARY with every deviation from the checkpoint report
- deferred items: sm-demo-app push and deploy wait for the user, release
  builds on summercms v0.4.4 lack the cabana subject-event fix, Redis for a
  second instance, the real-client check, the retracted mimetype
2026-10-23 13:17:36 +02:00
Jakub Zych
9550fe0a9f docs(19.7-04): deferred items from the oauthserver, Journal and demo MCP wiring 2026-10-23 12:56:08 +02:00
Jakub Zych
29ba9a28cb docs: log cabana subject-event fix 2026-10-23 12:44:15 +02:00
Jakub Zych
7c43820afe docs(19.7): cabana subject-event bug resolved 2026-10-23 12:44:09 +02:00
Jakub Zych
d96c726d6c fix(cabana): backend subject events reach pier as *pact.MCPSubjectChanged, fired after the transaction commits 2026-10-23 12:44:09 +02:00
Jakub Zych
5c35f83b9d docs(phase-19.7): update tracking after wave 3 2026-10-23 12:31:00 +02:00
Jakub Zych
607658599f docs(19.7-03): sm-user-plugin v0.1.10 released and pinned in five apps; cabana subject-event bug deferred
Plan 03 summary: verified account link, Administrators-screen link actions, plugin frontend permissions, user MCP guard and user_me in sm-user-plugin v0.1.10 (requires SummerCMS v0.4.4). deferred-items: cabana fires pact.MCPSubjectChanged as a value inside the transaction while pier listens for a pointer; fix before v0.5.0.
2026-10-23 12:30:15 +02:00
Jakub Zych
d039350729 docs: record the v0.4.4 and sm-user-plugin v0.1.9 releases 2026-10-23 00:31:56 +02:00
730 changed files with 118946 additions and 2030 deletions

View File

@@ -48,5 +48,7 @@ A new phase, inserted or added, gets its task when it is added to `ROADMAP.md`.
| | | 19.4.1 | g02hcroj |
| | | 19.6.1 | hioyvbxa |
| | | 19.8 | t9k2efpy |
| | | 32 | 8gldeain |
| | | 33 | ebilbgte |
Backlog phase 999.1 (newsletter) has no task until it is promoted into the roadmap.

View File

@@ -1,40 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-10-20T09:20:51.976Z",
"phase": "19.4.1",
"phase_name": "Early public demo on demo.summercms.io (INSERTED)",
"phase_dir": ".planning/phases/19.4.1-early-public-demo-on-demo-summercms-io",
"plan": 1,
"task": 4,
"total_tasks": 4,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "Tracer: English deck from ROADMAP.md shown in bm's viewer", "status": "done", "commit": "b80aaaf, 7a4a6b0, 4514159 (sm-demo-app)"},
{"id": 2, "name": "Whole deck in EN and PL: roadmap, features, migration, pxpx, MCP future", "status": "done", "commit": "680fd82 (sm-demo-app)"},
{"id": 3, "name": "OIDC provider screenshots, no duplicate images on re-seed", "status": "done", "commit": "37ee65e (sm-demo-app)"}
],
"remaining_tasks": [
{"id": 4, "name": "checkpoint:human-verify — user reviews the EN/PL deck for the 2026-10-21 presentation", "status": "awaiting_user"}
],
"blockers": [
{"description": "Plan 19.4.1-01 Task 4 is a blocking human-verify checkpoint", "type": "human_action", "workaround": "none — user must approve or list slide changes"}
],
"async_jobs": [],
"human_actions_pending": [
{"action": "Review both decks (30 slides each) and reply 'approved' or list slide changes (slide number, language, new text)", "context": "Deck is the user's main presentation on 2026-10-21", "blocking": true},
{"action": "Decide whether the continuation agent should add a PRESENT_HOST env var to sm-demo-app/scripts/dev.sh (bind present servers to 0.0.0.0 for 10.8.3.4)", "context": "Currently done via an untracked scratchpad copy of dev.sh", "blocking": false},
{"action": "Optionally report how the speaker view (/moje-prezentacje/roadmap) behaved without Centrifugo", "context": "Executor could not log in (visitor password lives in .env, which the secret guard blocks)", "blocking": false}
],
"decisions": [
{"decision": "Phase runs sequentially on main tree (workflow.use_worktrees=false); code commits go to sibling repos, planning commits to summercms.go", "rationale": "Plans target ../sm-demo-app and other siblings", "phase": "19.4.1"},
{"decision": "Present servers bound to 0.0.0.0 via a scratchpad copy of dev.sh rather than editing dev.sh", "rationale": "Avoid uncommitted edits in a file the continuation agent will touch", "phase": "19.4.1"}
],
"uncommitted_files": [
" M .planning/STATE.md",
" M .planning/state.json",
"?? .msd/",
"?? .planning/phases/19.4.1-early-public-demo-on-demo-summercms-io/.gitkeep"
],
"next_action": "/msd-execute-phase 19.4.1 → it finds 19.4.1-01 without SUMMARY but with commits (safe_resume_gate); choose to continue at Task 4 by spawning a continuation msd-executor with the user's deck response, then write 19.4.1-01-SUMMARY.md including deviations 1-9 from .continue-here.md",
"context_notes": "sm-demo-app is clean; 5 plan commits after 1439b3a, unpushed. No SUMMARY yet. STATE.md/state.json modifications are from state.begin-phase (and possibly other sessions) — leave alone, do not commit them with the handoff."
}

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**
@@ -1396,20 +1396,20 @@ Plans:
3. Device authorization (`devices/initiate`, `devices/status/{token}`, `devices/authorize`, and the device list and revoke) works on the `DeviceAuthSession` model.
4. The models TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential and DeviceAuthSession and their migrations are ported. The phase gets a security review. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 4 plans
**Plans:** 4/4 plans executed
Plans:
**Wave 1**
- [ ] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
- [x] 19.6-01-PLAN.md — Foundations: decision note for pquerna/otp, go-webauthn and boombuler/barcode, sm-user-plugin seams (login gates in Login with the restore after the gate, login and password-changed events, reset-2FA slot and permission), device authorization flow with the JTI followed through refresh, human package check (wave 1)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
- [x] 19.6-02-PLAN.md — New repo sm-mfa-plugin: TOTP tracer through the challenge, recovery and email codes with the D-18 hardening, trusted devices, settings screen, revocation on password change, admin reset service (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
- [x] 19.6-03-PLAN.md — WebAuthn second factor and passwordless passkeys, social sign-in through the gate, demo 2FA screens in vue-demo-app, release go-ahead and publishing in D-17 order, adoption in sm-demo-app and sm-bm-app, fonoteka.go pointer and guard tests (wave 3)
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
- [x] 19.6-04-PLAN.md — Unit tests for every decision, login-path invariant, PHP security test map, security review with the explicit D-14 section, scripts/check-phase19.6.sh, gated release of post-release fixes (wave 4)
### Phase 19.6.1: Backend Administrators screen (INSERTED)
@@ -1454,7 +1454,7 @@ Plans:
3. `tools/list` lists only the tools the session's principals may call (frontend permissions through the merged group/user permissions, backend permissions and token scope through cabana), and a call runs through the route's own middleware.
4. An editor signs in once through `wristband` OAuth as a frontend user; with a verified link to a backend user, backend-guarded tools such as Journal's run as that admin, and removing the link or the grant removes them. The phase gets a security review; the MCP SDK dependency has a decision note before code lands; unit tests are the last plan.
**Plans:** 2/5 plans executed
**Plans:** 9/9 plans executed
Plans:
**Wave 1**
@@ -1464,13 +1464,27 @@ Plans:
- [x] 19.7-02-PLAN.md — The `pier` module behind `summercms_mcp` (go-sdk, hybrid stateful/stateless, Redis sessions, fan-out, PRM) and the framework release carrying 19.6.1 + 19.7-01/02
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 19.7-03-PLAN.md — sm-user-plugin: verified account link, Administrators-screen link actions, plugin frontend permission codes, the `user` MCP guard and `user_me` tool, plugin release and app pointer bumps
- [x] 19.7-03-PLAN.md — sm-user-plugin: verified account link, Administrators-screen link actions, plugin frontend permission codes, the `user` MCP guard and `user_me` tool, plugin release and app pointer bumps
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 19.7-04-PLAN.md — sm-oauthserver-plugin `backend` scope, `oas_at_` verifier and MCP consent JSON; Journal tools; vue-demo-app `/connect` and `/link-admin`; sm-demo-app wiring, e2e and deploy notes; plugin releases
- [x] 19.7-04-PLAN.md — sm-oauthserver-plugin `backend` scope, `oas_at_` verifier and MCP consent JSON; Journal tools; vue-demo-app `/connect` and `/link-admin`; sm-demo-app wiring, e2e and deploy notes; plugin releases
**Wave 5** *(blocked on Wave 4 completion)*
- [ ] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
- [x] 19.7-05-PLAN.md — Unit tests across six repos, `scripts/check-phase19.7.sh`, the filled validation map and the security review
Gap closure (`19.7-REVIEW.md` findings, escalations E-1 and E-2; `19.7-GAP-INPUTS.md`). No tag, push or deploy until verification; then framework v0.5.0, the plugin tags, the app bumps.
**Gap wave 1**
- [x] 19.7-06-PLAN.md — pier hardening (lane A): session reaper, atomic per-binding reservation, no global lock across guard I/O, identity widening, Redis fan-out for stateless instances, listen re-check, path-value rules, fixed error text, prompt write ceiling
**Gap wave 2** *(blocked on 19.7-06: shared pier and pact files)*
- [x] 19.7-07-PLAN.md — Framework auth and admin (lane B): `auth_time` claim, `pact.HasFrontendTokenScopes` seam (E-2), token-specific revoke events, client address on the resolve path, My account URL and section isolation, hotkey fixes, OpenAPI and dist
**Gap wave 3** *(blocked on 19.7-06 and 19.7-07)*
- [x] 19.7-08-PLAN.md — Plugins and demo (lane C): link proof on `auth_time` with the code in the fragment, grant revocation on ban/reset and link changes, member token scopes (E-2), organisation logo (E-1), Journal prompt ceiling, nginx MCP body cap
**Gap wave 4** *(blocked on 19.7-06, 19.7-07 and 19.7-08)*
- [x] 19.7-09-PLAN.md — Unit tests for every fix and both escalations, the extended gate (workspace check, unreleased-pin policy), validation rows, the corrected security review, deferred items
### Phase 19.8: Console commands overhaul (INSERTED)
@@ -1519,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
@@ -1559,6 +1596,175 @@ Plans:
Plans:
- [ ] TBD (run /msd-plan-phase 31 to break down)
### Phase 32: SEO foundation
**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. This covers:
- three framework seams that name nothing SEO-specific: cross-plugin admin form extensions with `Before`/`After` field and tab placement, cabana record events, signpost content descriptors;
- `sm-seo-plugin` (Golem15.SEO): the SEO tab on registered content types, global SEO settings with per-type templates, per-URL rules;
- title, meta description, canonical, meta robots and `X-Robots-Tag`, Open Graph and Twitter Card;
- 301/302/410 redirects, the 404 log, URL normalisation and strict URLs;
- the robots.txt editor, AI bot switches, verification files;
- MCP tools for all of it, with batch dry-run, CSV import/export and history with undo.
**Mode:** mvp
**Depends on:** Phase 17 (signpost, sitemap plugin), Phase 19.7 (pier MCP)
**Repos:** `summercms.go` (pact, cabana, signpost seams; framework release tag at the end), new `sm-seo-plugin`, `sm-journal-plugin` (signpost descriptors), the apps that mount the plugin
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
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.
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.
3. Changing a record's slug creates a 301 and flattens existing chains; redirect loops are refused from the admin, MCP and imports.
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:** 6/6 plans executed
Plans:
**Wave 1**
- [x] 32-01-PLAN.md — D-91 cross-plugin admin form extensions (pact types, cabana placement and save/show wiring, docs, SPA backstop)
**Wave 2** *(blocked on Wave 1 completion)*
- [x] 32-02-PLAN.md — D-92 record events and D-93 content descriptors; Journal descriptors, journal-tag, API event emission, translated slugs
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 32-03-PLAN.md — sm-seo-plugin core: SEO tab, URL rules, settings with per-type tabs, resolver, history, generate job, elements E1-E3/E7
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 32-04-PLAN.md — redirects and address-change 301, 404 log, normalisation, strict URLs, robots.txt with AI bots, verification files
**Wave 5** *(blocked on Wave 4 completion)*
- [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)*
- [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
**Goal:** As a site editor and as an SEO specialist auditing a SummerCMS site, I want the rendered sites to 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. Decisions are in `.planning/phases/32-seo-foundation/32-CONTEXT.md`. This covers:
- the `sm-sitemap-plugin` rewrite to a sitemap index split by type (breaking, D-32), image/News/IndexNow options, the HTML sitemap;
- the page registry in `sm-seo-plugin`;
- JSON-LD `@graph` per page type, pagination, hreflang in head and sitemaps;
- heading rules (no H1 in content), breadcrumbs;
- a shared Nuxt SEO layer, with the SSR build guard and the layers and apps moved onto it;
- the crawler and issue report;
- internal linking, auto-links and the orphan report;
- media SEO (alt, file names, formats).
**Mode:** mvp
**Planning:** exception to the lean-planning rule approved by Jakub on 2026-10-25: about 12–15 smaller plans instead of a few large ones.
**Depends on:** Phase 32
**Repos:** `sm-sitemap-plugin`, `sm-seo-plugin`, `sm-journal-plugin`, `sm-faq-plugin`, the Nuxt layers (new SEO layer, journal, menu, FAQ), the apps (grzybyfunkcjonalne migrates its sitemap in the same delivery)
**Requirements**: TBD
**Success Criteria** (what must be TRUE):
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, and grzybyfunkcjonalne.pl runs on the new format.
2. Indexable pages render server-side with head tags, one JSON-LD `@graph`, hreflang and breadcrumbs from the CMS, and a build fails when an indexable route has `ssr: false`.
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:** 16/16 plans executed
Plans:
**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
**Execution Order:**
@@ -1605,13 +1811,16 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 19.4. OIDC provider on wristband | 4/4 | Complete | 2026-10-20 |
| 19.4.1. Early public demo on demo.summercms.io | 5/5 | In Progress| |
| 19.5. Social login in sm-user-plugin | 5/5 | Complete | 2026-10-21 |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 4/4 | In Progress| |
| 19.6.1. Backend Administrators screen (INSERTED) | 5/5 | Complete | 2026-10-22 |
| 19.7. Generic MCP module | 2/5 | In Progress| |
| 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 | 6/6 | In Progress| |
| 33. SEO output, audit and content | 16/16 | In Progress| |
| 34. Plugin marketplace | 6/6 | In Progress| |
## Backlog
@@ -1635,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

@@ -3,17 +3,17 @@ msd_state_version: "1.0"
milestone: v1.0
current_phase: "19.7"
current_phase_name: Generic MCP module (INSERTED)
status: executing
stopped_at: Completed 19.7-02-PLAN.md
last_updated: "2026-10-22T22:22:58.311Z"
status: verifying
stopped_at: Phase 32 context gathered
last_updated: "2026-10-25T21:26:24.117Z"
last_activity: 2026-10-23
last_activity_desc: "Completed quick task 261022-x7a: user-plugin golem15.user.password.public_routes switch (login/forgot/reset 404 when false)"
state_head: a26ee2b72d5b99db7596217ce1c3da7ec0c6bf89
last_activity_desc: "Completed 19.7-08: plugin gap closure, E-1 logo, E-2 member token scopes; paused before 19.7-09"
state_head: 9a76c7ec1c0c17e93e00086231af78648eb40c43
progress:
total_phases: 45
total_phases: 47
completed_phases: 37
total_plans: 238
completed_plans: 231
total_plans: 242
completed_plans: 238
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -30,9 +30,9 @@ See: .planning/PROJECT.md (updated 2026-10-22)
## Current Position
Phase: 19.7 (Generic MCP module (INSERTED)) — EXECUTING
Plan: 3 of 5
Status: Ready to execute
Last activity: 2026-10-23 — Completed quick task 261022-x7a: user-plugin golem15.user.password.public_routes switch (login/forgot/reset 404 when false)
Plan: 5 of 5
Status: Phase complete — ready for verification
Last activity: 2026-10-23 — Phase 19.7 execution started
Progress: [████████░░] 82%
@@ -314,6 +314,9 @@ Progress: [████████░░] 82%
- Phase 19.4.1 inserted after Phase 19.4: Early public demo on demo.summercms.io: today's 16 shared plugins on rome, demo accounts, journal/presentations/quizzes/OIDC pages, backend by request; Phase 20 narrows to the chat family (URGENT)
- Phase 32 added: Console commands overhaul: diary logging module, bonfire global flags and Artisan-grade help, sweep of all commands, tests
- Phase 19.6.1 inserted after Phase 19.6: Backend Administrators screen: admins and roles CRUD in cabana (gap found while planning 19.7: no screen to manage backend users or set the frontend link) (URGENT)
- Phase 32 added: SEO foundation: per-record and global SEO, structured data per page type, sitemap boost and HTML sitemap, pagination, redirects, robots.txt, canonical, meta robots, social tags, headings, hreflang, noindex policy, SSR for headless frontends
- Phase 32 edited: narrowed to framework seams and sm-seo-plugin core (split decided 2026-10-25)
- Phase 33 added: SEO output, audit and content; large phase, lean-planning exception
### Decisions
@@ -950,6 +953,7 @@ Recent decisions affecting current work:
- [Phase 19.7]: v0.5.0 is tagged only when phase 19.7 is fully complete (plans 01-05 and phase verification); supersedes the earlier 19.7-02 Task 5 entry. Plan 02 closes without a tag; no intermediate framework tag for 19.7 work — User decision 2026-10-23: a minor version marks the finished MCP phase, not a partial one
- [Phase 19.7]: Once the user confirms all in-flight msd-quick tasks are finished: release the framework as the next v0.4.x patch (carrying the quick patches and 19.7 work so far) and tag the held user-plugin fixes; plugins may be tagged mid-phase. Framework v0.5.0 stays reserved for full MCP usability at phase 19.7 completion — User decision 2026-10-23: plugins can be tagged now, framework 0.5 means the MCP phase is finished; wait for the user's word that the last quick agent is done
- [Phase 19.7]: 19.7-02: pier serves 2025-11-25 clients through its own session router over StreamableServerTransport (Redis-shared sessions, D-25) and 2026-07-28 clients through the SDK stateless handler; plan 02 closed without a tag, pier ships opt-in in v0.4.4 (summercms_mcp tag plus mcp.enabled, default off)
- [Phase 19.7]: Released framework v0.4.4 (e2759d6, changelog id 39) and sm-user-plugin v0.1.9 (3828a65; requires v0.4.3); sm-demo-app bumped to user v0.1.9 locally (a25444e, unpushed). Other apps not bumped yet — User go-ahead 2026-10-23 after all quick tasks finished; all release gates green at e2759d6
### Pending Todos
@@ -996,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 |
@@ -1054,6 +1064,15 @@ Recent decisions affecting current work:
| 261022-vfz | User plugin /login throttle keyed on surf.ClientIP (trusted proxies); reset-password and admin password set stamp tokens_valid_after in whole seconds (sm-user-plugin b6f8055, a484d0e, ships v0.5.0; verified) | 2026-10-22 | b6f8055, a484d0e (sm-user-plugin) | [261022-vfz-user-plugin-login-throttle-via-surf-clie](./quick/261022-vfz-user-plugin-login-throttle-via-surf-clie/) |
| 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
@@ -1065,6 +1084,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-22T22:22:57.128Z
Stopped at: Completed 19.7-02-PLAN.md
Resume file: None
Last session: 2026-10-24T14:47:37.432Z
Stopped at: Phase 32 context gathered
Resume file: .planning/phases/32-seo-foundation/32-CONTEXT.md

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,46 @@
# Decision: second factors use pquerna/otp, go-webauthn and boombuler/barcode, in sm-mfa-plugin only
**Date:** 2026-10-21
**Status:** Accepted
**Context:** Phase 19.6 (two-factor auth and device flow, D-01, D-04, D-06, D-08, D-14), 19.6-RESEARCH.md Q1, Q2, Q3 and Package Legitimacy Audit
## Decision
The second factors (TOTP, WebAuthn, email codes, recovery codes, trusted devices) live in the new shared plugin `sm-mfa-plugin`, which requires `sm-user-plugin` (D-01). Three third-party modules carry the protocol work; everything else is the standard library and the framework.
- **`github.com/pquerna/otp` pinned at v1.5.0.** Published 2024-12-31, Apache-2.0. Packages `totp` and `hotp` only. Issuer is the application name, the account is the user's email, the validation window is plus or minus one step, SHA1, 6 digits, 30 s period (the Google Authenticator defaults PHP used). Replay protection is done per counter with `hotp` (RESEARCH Pitfall 5).
- **`github.com/go-webauthn/webauthn` pinned at v0.18.2.** Published 2026-09-19, BSD-3-Clause. Packages `webauthn` and `protocol` only. Attestation `none`, timeout 60 s, user verification `preferred` for second-factor use and `required` for passkeys (D-14). Ceremony state stays server-side.
- **`github.com/boombuler/barcode` pinned at v1.1.0 (package `qr` only) for the TOTP QR code.** MIT. It is already a requirement of pquerna/otp (for `Key.Image()`), so it adds no module and no maintainer. sm-mfa-plugin writes the SVG itself (a small serializer emitting one `<path>` for the dark modules, `width="200" height="200"` with a `viewBox`, as PHP's Bacon `RendererStyle(200)` did), so PHP's TOTP setup contract `{secret, qr_code_svg, qr_code_url}` stays. The round trip was proven during research: a TOTP URL encoded at level M was read back byte for byte by an independent decoder (RESEARCH Q2, Assumption A1).
- **Slow hash for recovery and email codes: bcrypt** through `bouncer.HashPassword` / `bouncer.CheckPassword` at `golem15.user.password.bcrypt_cost`, exactly as the user plugin hashes passwords (D-06). No argon2: it would add `golang.org/x/crypto/argon2` usage the framework does not have. Ten compares at cost 10 measured 631 ms (RESEARCH Q3).
## Measurements (RESEARCH Q1)
- **Binary size: +2,269,184 bytes (+2.16 MiB)** on a stripped build of the demo application (`-trimpath -ldflags="-s -w"`): 51,802,375 to 54,071,559 bytes, with TOTP generate and validate, QR encode, and the WebAuthn registration, login and passkey-login paths driven by a probe.
- **Nine newly linked modules** (`go version -m` diff): `github.com/boombuler/barcode` v1.1.0, `github.com/fxamacker/cbor/v2` v2.9.4, `github.com/go-webauthn/webauthn` v0.18.2, `github.com/go-webauthn/x` v0.3.1, `github.com/google/go-tpm` v0.9.8, `github.com/philhofer/fwd` v1.2.0, `github.com/pquerna/otp` v1.5.0, `github.com/tinylib/msgp` v1.6.4, `github.com/x448/float16` v0.8.4.
- **One version bump in consuming graphs:** minimal version selection moves `github.com/go-viper/mapstructure/v2` from v2.4.0 to v2.5.0 in the module graph of applications that mount the plugin. `golang-jwt/jwt/v5` v5.3.1, `google/uuid` v1.6.0 and `golang.org/x/crypto` v0.57.0 are already at go-webauthn's versions.
- **Vulnerabilities:** `govulncheck` v1.9.0 reports no reachable vulnerability. The only module-level advisory, GO-2026-5932 (`golang.org/x/crypto/openpgp`, unmaintained), sits in a package nothing imports.
- go-webauthn's go.mod lists the archived YAML v3 module as an indirect, test-only requirement outside the import graph. Plugin code never imports it (CLAUDE.md: `goccy/go-yaml` only).
## Deviation from PHP recorded here (D-08)
WebAuthn requests and responses use go-webauthn's standard base64url shapes (`PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions` and the attestation and assertion JSON that `navigator.credentials` and `@simplewebauthn/browser` produce), not PHP's lbuchs plain-base64 layout. The paths and outer keys stay PHP's. This is acceptable because no client implements the PHP layout yet. PHP stored PEM public keys while go-webauthn verifies COSE keys, so the Phase 30 Winter import must convert or drop WebAuthn rows.
## Rejected alternatives
- **`github.com/piglig/go-qr` v1.1.0** (native `ToSVGBytes`). 67 stars, one maintainer, and a new module for what boombuler already provides through pquerna/otp's graph.
- **argon2id for codes.** Not what the user plugin uses for passwords; D-06 asks to match it.
- **A hand-written TOTP or WebAuthn implementation.** Protocol and security code goes to a maintained library with a decision note (project preference).
## Related dependencies
- `golang.org/x/crypto`, `github.com/golang-jwt/jwt/v5` and `github.com/google/uuid` are already in the framework and application graphs at the versions go-webauthn needs.
## Package legitimacy
The package-legitimacy seam cannot check Go modules (as in 19.4 and 19.5), so all three are [ASSUMED] for legitimacy and gated by a blocking human check (plan 19.6-01 Task 5) before any `go get`. Signals gathered by hand: pquerna/otp v1.5.0 on proxy.golang.org (2,963 stars, Apache-2.0); go-webauthn/webauthn v0.18.2 (1,351 stars, BSD-3-Clause, pushed 2026-10-20); boombuler/barcode v1.1.0 (published 2025-07-22, 1,563 stars, MIT).
## Consequences
- Only `sm-mfa-plugin`'s go.mod requires the three modules. `sm-user-plugin`, the framework and fonoteka.go require none, and applications without the plugin link none of the nine modules (D-01).
- go-webauthn is pre-1.0 and the research is valid until 2026-11-20. After that date the executor re-checks the pin with `go list -m -json github.com/go-webauthn/webauthn@latest` before installing.
- Installation, only after the human check: `go get github.com/pquerna/otp@v1.5.0 github.com/go-webauthn/webauthn@v0.18.2 github.com/boombuler/barcode@v1.1.0` inside `sm-mfa-plugin`, then `go mod tidy`.

View File

@@ -46,3 +46,5 @@ The package-legitimacy seam checks npm, PyPI and crates only, so both modules we
- `grep -rl modelcontextprotocol/go-sdk modules --include=*.go` must list only `modules/pier/*` and `modules/surf/pier_on.go`; the phase gate (plan 05) asserts it, together with "only `modules/pier` calls `bouncer.WithDispatch` outside tests".
- `examples/hello` and every application built without the tag carry no MCP code; `go version -m` of such a binary is the proof.
- Applications that enable MCP add `summercms_mcp` to `summer.yaml` `build.tags` and set `mcp.enabled: true`; the `mcp.*` keys are documented in `modules/pier/README.md` and `docs/services/mcp.md`.
Released in v0.4.4 (2026-10-23), opt-in behind the `summercms_mcp` build tag and `mcp.enabled`; the full MCP feature ships as v0.5.0 at phase 19.7 completion.

View File

@@ -0,0 +1,51 @@
# Decision: member token scopes use a separate pact capability
**Date:** 2026-10-23
**Status:** Accepted
**Context:** Phase 19.7 gap closure, escalation E-2 (`19.7-GAP-INPUTS.md`), planner decision P-30 in plan 19.7-07 Task 2
## Context
sm-servers-app escalated E-2: member personal API tokens (`usr_`), which the user plugin issues, can only carry the fixed scopes `read`, `write` and `ai`. A plugin cannot declare its own, so a narrow agent token (`servers:read`, `servers:write`) falls back to the generic `write`, and every application sharing the token table gives the same scope a different meaning.
What was asked:
- the user plugin builds its allowed scope list from the built-ins plus plugin-declared scopes, validates names the way cabana does (pattern, no duplicates, no clash with the built-ins) and fails boot on a bad one;
- minting, checking (`middleware.Scope`, `APIToken.HasScope`) and listing accept the declared scopes with no special casing;
- a discovery endpoint or helper returns the scopes with name, translated label and description.
What was not asked for:
- a mint-time hook refusing a scope by the member's role;
- any change to backend `g15_` tokens or the cabana token catalog;
- any change to `tokens_valid_after` handling.
The escalation left one design point open: reuse `pact.TokenScope`, or a twin for the frontend catalog.
## Options
1. **Reuse `pact.HasTokenScopes`.** The user plugin reads the same capability cabana reads.
2. **An audience field on `pact.TokenScope`** (`Audience: "backend" | "frontend"`), with each catalog skipping the other audience's entries.
3. **A separate capability**, `pact.FrontendTokenScope{Name, Label, Description}` and `pact.HasFrontendTokenScopes{ FrontendTokenScopes() []FrontendTokenScope }`, mirroring the existing `pact.Permission` / `pact.FrontendPermission` pair.
## Decision and reasons
Option 3, a separate capability. pact also gains `pact.ValidTokenScopeName(name string) bool`, carrying cabana's existing pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`, so both catalogs validate names identically.
1. **The escalation asks for no change to the `g15_` catalog.** Reusing `HasTokenScopes` would show every member scope in the admin's token catalog and every admin scope in the member list. The two token kinds belong to different principals (an administrator against cabana permissions, a member against frontend routes); one list cannot describe both.
2. **The admin type's fields do not apply to a member token.** `pact.TokenScope` carries `Permissions` (cabana permission patterns) and `Methods` (an HTTP method filter). A member route's `user.scope:<name>` middleware alone decides what a member scope allows. cabana's boot validation also requires both fields to be non-empty and the patterns to match registered permissions, so a member scope could not even be declared through it.
3. **An audience field leaves fields meaningless for one audience.** `Permissions` and `Methods` would be dead for `frontend`, cabana would have to skip entries silently, and a typo in the audience would move a scope between catalogs without an error.
## Consequences
- The user plugin (sm-user-plugin, plan 19.7-08 Task 3) compiles `HasFrontendTokenScopes` from the active plugins, validates each name with `pact.ValidTokenScopeName`, refuses duplicates and clashes with `read`, `write` and `ai`, and fails boot on a bad scope. It owns minting, checking, listing and the discovery endpoint.
- The framework never reads `HasFrontendTokenScopes`; cabana's `g15_` catalog is unchanged in behaviour and in every error message (it now calls `pact.ValidTokenScopeName` instead of its own copy of the pattern).
- `pact.ValidTokenScopeName` is the single name rule; a future change to the pattern changes both catalogs together.
- Reversibility is costly: `pact.FrontendTokenScope`, `pact.HasFrontendTokenScopes` and `pact.ValidTokenScopeName` are published framework API, so a reshape needs a deprecation cycle.
## Related
- `.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md`, section "E-2"
- `.planning/phases/19.7-generic-mcp-module/19.7-07-PLAN.md`, P-30 and Task 2
- `.planning/phases/19.7-generic-mcp-module/19.7-08-PLAN.md`, Task 3 (the user plugin side)
- `modules/pact/capabilities.go`, `modules/cabana/token_scopes.go`

View File

@@ -0,0 +1,33 @@
# Decision: the robots.txt tester uses grobotstxt
**Date:** 2026-10-27
**Status:** Accepted
**Context:** Phase 32 (SEO foundation, D-22, D-24, D-99), 32-RESEARCH.md Standard Stack and Package Legitimacy Audit, plan 32-04 Task 4
## Decision
The robots.txt tester of `sm-seo-plugin` (element E5, widget action `robots_test`) and the check that warns when robots.txt blocks a noindex address (D-24) match URLs with `github.com/jimsmart/grobotstxt`.
- **`github.com/jimsmart/grobotstxt` pinned at v1.0.3.** Published 2022-03-16 (proxy.golang.org), Apache-2.0, origin `github.com/jimsmart/grobotstxt` at tag `v1.0.3`. It is the newest tag.
- **Why this library.** It is a function-for-function Go port of Google's C++ robots.txt parser and matcher (`google/robotstxt`), so the tester answers what Googlebot does:
- the longest matching rule wins, and `Allow` wins a tie with `Disallow`;
- `*` matches any run of characters and `$` anchors the end;
- user-agent groups are chosen as Google chooses them (the most specific group, else `*`);
- `RobotsMatcher.MatchingLine()` gives the line number of the deciding rule (0 when no rule matched), which E5 shows to the editor.
- **What the plugin calls:** `NewRobotsMatcher()`, `(*RobotsMatcher).AgentAllowed(robotsBody, userAgent, uri)` and `MatchingLine()`. Nothing else, and only in `sm-seo-plugin/classes/robots.go`.
- **Plugin-only.** Only `sm-seo-plugin` requires it. The framework `go.mod` does not, and `grep jimsmart/grobotstxt go.mod` in summercms.go stays empty.
- **Imported packages:** the library imports the standard library only (`go list -deps` on a scratch module lists `github.com/jimsmart/grobotstxt` as its single non-stdlib package). Its go.mod requires ginkgo and gomega for its own tests; they reach a consumer's go.sum and are never compiled into a binary.
## Rejected alternatives
- **`github.com/temoto/robotstxt` v1.1.2** (2021, MIT). Older, with its own parser, no matching-line API, and no documented Google longest-match semantics.
- **A hand-written RFC 9309 matcher** (about 200 lines). Standard library first and no stale dependency, but protocol code the project prefers to take from a maintained library, and Google's behaviour has edge cases the port already covers. RFC 9309 is frozen (2022), so a 2022 release of a faithful port is not a staleness risk in practice.
## Package legitimacy
The package-legitimacy seam cannot check Go modules, so grobotstxt was gated by a blocking human check (plan 32-03 Task 4) and approved by Jakub before any `go get`. The orchestrator verified that v1.0.3 resolves through proxy.golang.org; the licence file in the module is Apache-2.0.
## Consequences
- `sm-seo-plugin/go.mod` requires `github.com/jimsmart/grobotstxt v1.0.3`; applications that mount the plugin get it through the plugin.
- If the library ever needs replacing, the tester's answer shape (allowed or blocked, the line and its text, blocked by a bot switch) is the plugin's own, so only `classes/robots.go` changes.

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,290 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
plan: 01
subsystem: auth
tags: [sm-user-plugin, login-gate, device-flow, jwt, jti, blacklist, 2fa, gormigrate]
requires:
- phase: 19.4
provides: sm-user-plugin v0.1.x as its own repository (core plugin contract, additive changes only)
- phase: 19.5
provides: user API login/refresh/logout and the bouncer JWT blacklist
provides:
- D-04 decision note for pquerna/otp v1.5.0, go-webauthn/webauthn v0.18.2, boombuler/barcode v1.1.0 (human package check passed)
- Login-gate registry (classes.LoginGates) consulted by password Login before restore and mint
- classes.LoginEvent fired once per successful password login
- Password-change hook through (*classes.AccessRevokedEvent).PasswordChanged()
- Reset-2FA admin slot (permission golem15.users.reset_two_factor, record action reset_two_factor, classes.TwoFactorAdmin service)
- Device authorization by QR or short code (5 routes, golem15_user_device_auth_sessions table)
- Device list and revoke, with the device jti handed over on refresh
affects: [19.6-02, 19.6-03, 19.6-04, sm-mfa-plugin, sm-sociallogin-plugin]
actuals:
tokens: 27169 # chars/4 over added lines: sm-user-plugin 50da92c..3b22134 (101994 chars) + summercms.go ecc34bc..24d9d2a (6683 chars)
tasks: 5
commits: 1 # MEASURED in summercms.go: git rev-list --count ecc34bc..24d9d2a; sm-user-plugin measured separately: git rev-list --count 50da92c..3b22134 = 5
plan_head_before: ecc34bc35a653fee7241255e055d09b20f1f09b5
plan_head_after: 24d9d2a
sm_user_plugin_head_before: 50da92c
sm_user_plugin_head_after: 3b22134
tech-stack:
added: [] # the three libraries are decided and approved, not installed; plan 02 installs them in sm-mfa-plugin only
patterns:
- "Login-gate registry: a service published in Register that first-factor paths consult before minting"
- "Guarded single-row UPDATE for one-time state transitions (pending->confirmed, confirmed->used)"
- "Optional service lookup decides whether an admin record action Applies"
key-files:
created:
- .planning/notes/2026-10-21-mfa-otp-webauthn.md
- ../fonoteka.go/plugins/golem15/user/classes/login_gate.go
- ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go
- ../fonoteka.go/plugins/golem15/user/classes/device_auth.go
- ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go
- ../fonoteka.go/plugins/golem15/user/controllers/devices.go
- ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go
- ../fonoteka.go/plugins/golem15/user/updates/202610260001_create_device_auth_sessions.go
- ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go
- ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go
- ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go
- ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go
- ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go
modified:
- .planning/research/STACK.md
- ../fonoteka.go/plugins/golem15/user/classes/events.go
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/registration.go
- ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
- ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml
- ../fonoteka.go/plugins/golem15/user/admin_permissions.go
- ../fonoteka.go/plugins/golem15/user/plugin.go
- ../fonoteka.go/plugins/golem15/user/routes.go
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
- ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
- ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
- ../fonoteka.go/plugins/golem15/user/README.md
key-decisions:
- "D-07 reuses classes.AccessRevokedEvent (already fired in-transaction at all three password-write sites) plus (*AccessRevokedEvent).PasswordChanged(); no PasswordChangedEvent type exists. Plan 02 listens on *classes.AccessRevokedEvent and filters with ev.PasswordChanged()."
- "Device-session migration ID is 202610260001_create_device_auth_sessions so it sorts after the existing 202610220001 (the planned 202610210002 would have sorted before it)."
- "Device auth_url reuses golem15.user.frontend_url / classes.FrontendURL; the only new config keys are golem15.user.device_auth.auth_url and golem15.user.device_auth.ttl_minutes."
- "Task 5 package check approved by the user: plan 02 may install pquerna/otp v1.5.0, go-webauthn/webauthn v0.18.2 and boombuler/barcode v1.1.0 in sm-mfa-plugin."
patterns-established:
- "LoginGates: plugins Add a LoginGate in Register; first-factor handlers call controllers.CheckLoginGates before restore and mint, and controllers.FireLoginEvent after the mint"
- "One-time device JWT: confirmed->used guarded UPDATE, exactly one poll receives the token"
requirements-completed: [SC-2, SC-3, SC-4, D-02, D-03, D-04, D-06, D-07, D-11, D-12a, D-12b, D-12c, D-15, D-16, D-17, D-18]
coverage:
- id: D1
description: "D-04 decision note names the three pinned libraries, binary delta, linked modules, govulncheck run and the human package check; STACK.md lists the pins"
requirement: "D-04"
verification:
- kind: manual_procedural
ref: "Task 5 checkpoint reply: approved"
status: pass
human_judgment: true
rationale: "Package legitimacy is a human sign-off by design (blocking-human gate); given as \"approved\"."
- id: D2
description: "Login consults the gate registry after CheckAndRecordLogin and the rehash, before restore and mint; empty registry keeps old responses; erroring gate fails closed; challenged soft-deleted account stays deleted"
requirement: "D-02"
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go#TestLoginGatesSmoke, TestLoginGateSmoke (5 sub)"
status: pass
human_judgment: false
- id: D3
description: "Password writes (reset, change, admin set) fire the revocation event in-transaction with PasswordChanged() true; listener error rolls back; rehash fires nothing"
requirement: "D-07"
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go#TestPasswordChangedEventSmoke (5 sub)"
status: pass
human_judgment: false
- id: D4
description: "Reset-2FA permission and record action, shown only when a TwoFactorAdmin service is published"
requirement: "D-15"
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go#TestResetTwoFactorSlotSmoke"
status: pass
human_judgment: false
- id: D5
description: "Device initiate/status/authorize with hashed token, XXXX-XXXX short code, 5-minute TTL, one-time JWT on first confirmed poll"
requirement: "D-12a"
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go#TestDeviceCodesSmoke, TestDeviceFlowSmoke"
status: pass
human_judgment: false
- id: D6
description: "Device list and revoke; revoke blacklists the jti, also after refresh moved it"
requirement: "D-12b"
verification:
- kind: integration
ref: "../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go#TestDeviceRevokeSmoke (3 sub)"
status: pass
human_judgment: false
duration: 61min
completed: 2026-10-26
status: complete
---
# Phase 19.6 Plan 01: Foundations Summary
**sm-user-plugin gains a login-gate registry wired into password Login, a login event, a password-change hook on the existing revocation event, a Reset-2FA admin slot, and QR/short-code device authorization with a one-time JWT and jti-tracked revoke; the OTP, WebAuthn and QR libraries are decided and approved for plan 02.**
## Performance
- **Duration:** about 61 min
- **Started:** 2026-10-26T08:48:46Z
- **Completed:** 2026-10-26T09:49Z
- **Tasks:** 5 (4 code/docs tasks, 1 human checkpoint)
- **Files modified:** 31 in sm-user-plugin, 2 in summercms.go
## Base commits
- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`): phase base `50da92c` (v0.1.11). Plan 04's additive check diffs against it. HEAD after this plan: `3b22134`.
- **summercms.go**: base `ecc34bc` (ledger `.git/msd-plan-head-before-19.6-01`).
## Task Commits
In order, planning docs first:
| # | Repository | Commit | Message |
|---|------------|--------|---------|
| 1a | summercms.go | `24d9d2a` | docs(19.6): decide the OTP, WebAuthn and QR libraries |
| 1b | sm-user-plugin | `8501f34` | feat: login gates and the login event (tracer) |
| 2 | sm-user-plugin | `7900f47` | feat: password-change hook and the reset-2FA admin slot |
| fix | sm-user-plugin | `3d4c8d2` | style: gofmt the login gate smoke test |
| 3 | sm-user-plugin | `4e3e5b0` | feat: device authorization by QR or short code |
| 4 | sm-user-plugin | `3b22134` | feat: device list and revoke with the jti followed through refresh |
| 5 | none | none | Package check, resolved by the user's reply (below) |
Nothing was pushed or tagged.
## Task 5 reply
The user replied, verbatim:
> approved
Before the checkpoint, the module proxy confirmed the pins: pquerna/otp v1.5.0 (Apache-2.0, tag v1.5.0, `5971b1ef1d66`), go-webauthn/webauthn v0.18.2 (BSD-3-Clause, tag v0.18.2, `a4c6fc6a4399`, latest), boombuler/barcode v1.1.0 (MIT, tag v1.1.0, `11e32e438ffc`). Nothing was installed and no go.mod requires any of them. Plan 02 may now add them to sm-mfa-plugin's go.mod only.
## Accomplishments
- **Login gates (tracer, D-02, D-18).** `classes.LoginGates` is published in Register. Login checks it after `CheckAndRecordLogin(..., true)` and the bcrypt rehash, and before the soft-delete restore and the mint. A challenge answers the gate's own status and body with no token. A gate error returns the opaque 500. An empty registry leaves responses unchanged. Refresh never consults gates. A challenged soft-deleted account stays deleted and gets no reactivate mail.
- **Login event.** `classes.LoginEvent` (`golem15.user.login`) fires once after each successful mint, and never for a challenged login.
- **Password-change hook (D-07).** Uses the existing in-transaction `AccessRevokedEvent` plus `PasswordChanged()` (see Deviations).
- **Reset-2FA slot (D-15).** Adds the permission `golem15.users.reset_two_factor` (no default role) and the record action `reset_two_factor` (Danger) on the Users screen. The action appears only when a `classes.TwoFactorAdmin` service is published, and `Run` calls `Reset` inside cabana's transaction.
- **Device authorization (D-03, D-06, D-11, D-12a, D-12c, D-16).** Five routes in the user-api group:
- `POST devices/initiate`, `GET devices/status/{token}`, `POST devices/authorize`, `GET devices`, `DELETE devices/{id}`.
- The 64-character token is stored as SHA-256 hex. The short code is `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ` and the TTL is 5 minutes.
- Statuses match PHP (`pending|confirmed|used|expired|revoked`), plus a `jwt_jti` column.
- Guarded UPDATEs make pending->confirmed and confirmed->used happen once, so exactly one poll gets the JWT.
- **Revoke (D-12b).** `DELETE devices/{id}` marks the row revoked and blacklists its `jwt_jti`. Refresh hands the jti over to the new token (`HandOverDeviceJTI`), so a device that refreshed is still signed out.
- **Additive only (D-17).** No v0.1.11 route, body, table, column, migration ID or config key changed. fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile against the checkout.
## Exported identifiers and config keys (for plans 02 and 03)
Package `classes`:
- Login gates: `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin`), `LoginGates` (`NewLoginGates`, `Add`, `Check`)
- Login sources: `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice`
- Events: `LoginEvent`, `LoginEventName` (`"golem15.user.login"`), `(*AccessRevokedEvent).PasswordChanged()`
- Admin slot: `TwoFactorAdmin` (`HasTwoFactor`, `Reset`), `PermissionResetTwoFactor`
- Device helpers: `DeviceShortCodeAlphabet`, `NewDeviceToken`, `NewDeviceShortCode`, `NormalizeDeviceShortCode`, `DeviceAuthTTL`, `DeviceAuthURL`, `DeviceDisplayName`, `HandOverDeviceJTI`
Package `controllers`: `CheckLoginGates`, `FireLoginEvent`, `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex`, `DeviceDestroy`
Package `models`: `DeviceAuthSession`, `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired`, `DeviceStatusRevoked`
Permission `golem15.users.reset_two_factor`; record action `reset_two_factor`.
New config keys: `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`), `golem15.user.device_auth.ttl_minutes` (default 5). The existing `golem15.user.frontend_url` is reused.
## Tests
The full sm-user-plugin suite passed with `-short` and without it (Docker ran; no Docker-backed test was NOT RUN).
- New: TestLoginGatesSmoke, TestLoginGateSmoke (5 sub), TestPasswordChangedEventSmoke (5 sub), TestResetTwoFactorSlotSmoke, TestDeviceCodesSmoke, TestDeviceFlowSmoke, TestDeviceRevokeSmoke (3 sub).
- Unchanged and green: TestLogin, TestRefresh, TestLogout, TestAdminUserActions, TestAdminUserPassword, TestAdminRegistration.
- Downstream builds: fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin build against the checkout.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug avoidance] D-07 reuses AccessRevokedEvent; no PasswordChangedEvent**
- **Found during:** Task 2
- **Issue:** All three password-write sites already fire `classes.AccessRevokedEvent` inside their transaction, with Reasons `password_reset`, `password_changed` and `password_set_by_admin`. A second event would duplicate it.
- **Fix:** Added `(*AccessRevokedEvent).PasswordChanged()`. A listener error rolls the write back, and the login rehash fires nothing. The test keeps the name TestPasswordChangedEventSmoke. Task 2's greps for `PasswordChangedEvent` fail by design, but D-07 is met.
- **Impact on plan 02:** use `Listen[*classes.AccessRevokedEvent]` and filter with `ev.PasswordChanged()`.
- **Committed in:** `7900f47`
**2. [Rule 1 - Bug] Migration ID renamed to 202610260001_create_device_auth_sessions**
- **Found during:** Task 3
- **Issue:** The planned `202610210002` sorts before the existing `202610220001`, which breaks ordering.
- **Fix:** Used `updates/202610260001_create_device_auth_sessions.go`.
- **Committed in:** `4e3e5b0`
**3. [Rule 3 - Blocking] Config: frontend_url reused**
- **Issue:** `golem15.user.frontend_url` / `classes.FrontendURL` already exist.
- **Fix:** Reused them. The only new keys are `golem15.user.device_auth.auth_url` and `golem15.user.device_auth.ttl_minutes`.
**4. [Rule 3 - Blocking] sm-user-plugin checkout was detached at v0.1.10, behind origin**
- **Fix:** Checked out master and fast-forwarded to `50da92c` (v0.1.11), which is the phase base.
**5. [Rule 3 - Blocking] Downstream build checks used a scratch go.work**
- **Issue:** The sm-sociallogin-plugin checkout is owned by user `jin`, so go.work.sum is unwritable. sm-oauthserver-plugin's go.work points at sm-demo-app, which has another session's edit.
- **Fix:** Built both through a scratch go.work. Neither repository was modified.
**6. [Rule 2 - Missing critical] Hardening**
- Device rows record the client IP through `surf.ClientIP` with trusted proxies.
- The bcrypt rehash runs before the gates and is `Unscoped()`.
- `devices/authorize` reads only the body.
- Hand-over refuses deleted, deactivated or banned users and marks the row revoked.
- A row that has already expired answers with PHP's flat expired body.
- `ShortCode` is hidden in JSON.
- Hand-over runs FinishLogin and fires LoginEvent (Source device).
- A shared `sessionTTL` helper is added.
- **Committed in:** `4e3e5b0`, `3b22134`
**7. [Rule 1 - Bug] Pinned tests updated for additive items**
- TestAdminRegistration and TestAdminUsersTracer (permission catalogue), TestAdminUserActions (record actions), TestAdminUsersControllerGuards, TestPasswordPublicRoutes (23 to 28 routes), and four migration-count tests in `updates/`.
- **Committed in:** the task commits
**8. Separate style commit**
- `3d4c8d2`: gofmt of the login gate smoke test.
---
**Total deviations:** 8 (naming/reuse 3, environment 2, hardening 1, pinned tests 1, style 1)
**Impact on plan:** All changes are additive and keep the plan's decisions. Plan 02 must use the AccessRevokedEvent route for D-07 and the new migration ID.
## Issues Encountered
None beyond the environment items in Deviations 4 and 5.
## g15office
This session posted no comment on milestone task `avoroeb4`; the orchestrator will post it.
## User Setup Required
None.
## Next Phase Readiness
- Plan 02 can create sm-mfa-plugin, install the three approved libraries there, register a LoginGate, listen on `*classes.AccessRevokedEvent` with `PasswordChanged()`, and publish `classes.TwoFactorAdmin`.
- sm-user-plugin changes are local commits (`8501f34`..`3b22134`), not pushed or tagged. Plan 03 asks for the go-ahead.
---
*Phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin*
*Completed: 2026-10-26*
## Self-Check: PASSED
All seven commits (24d9d2a; sm-user-plugin 50da92c base, 8501f34, 7900f47, 3d4c8d2, 4e3e5b0, 3b22134) and the created files listed above exist.

View File

@@ -0,0 +1,369 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
plan: 02
subsystem: auth
tags: [sm-mfa-plugin, 2fa, totp, pquerna-otp, boombuler-barcode, qr-svg, recovery-codes, email-codes, trusted-devices, login-gate, cabana-settings, gormigrate]
requires:
- phase: 19.6-01
provides: sm-user-plugin login-gate registry (classes.LoginGates), FinishLogin/IssueToken/APIArray/FireLoginEvent, AccessRevokedEvent.PasswordChanged(), TwoFactorAdmin slot, approved library pins
provides:
- New local repository ../sm-mfa-plugin (module git.golem15.com/golem15/sm-mfa-plugin, package mfa, plugin golem15.mfa), no remote
- Login gate that answers PHP's {two_factor_required, challenge_token, available_methods, expires_at} for users with an enabled method
- Challenge routes /_user/api/v1/2fa/{verify,recovery,email/send} behind the 2fa-verify bucket (10/min per IP)
- Management routes status, totp/setup|confirm, DELETE totp, email/enable, DELETE email, recovery-codes (count, regenerate), trusted-devices (revoke one, revoke all)
- TOTP with SVG QR code, per-step replay refusal; recovery codes; email codes (A8, A9); per-user failure cap (A6); trusted devices (D-10) revoked on password change (D-07)
- Settings screen mfa (golem15_mfa_settings) applied per request; classes.Admin published as userclasses.TwoFactorAdmin (D-15)
- Five migrations 202610220001..202610220005
affects: [19.6-03, 19.6-04, sm-demo-app, sm-bm-app, vue-demo-app, sm-sociallogin-plugin]
actuals:
tokens: 52122 # chars/4 over every tracked file of ../sm-mfa-plugin except go.sum (208487 chars); go.sum adds 45100 chars
tasks: 3
commits: 0 # MEASURED in summercms.go: git rev-list --count 33920ed..33920ed (all code lives in ../sm-mfa-plugin)
sm_mfa_plugin_commits: 3 # MEASURED: git -C ../sm-mfa-plugin rev-list --count HEAD (5c6ad2f, 43d983b, 8a63f6f)
plan_head_before: 33920ed558f19cda2b380340033b5fb95848dcb9
plan_head_after: 33920ed558f19cda2b380340033b5fb95848dcb9
sm_mfa_plugin_head_before: "(new repository)"
sm_mfa_plugin_head_after: 8a63f6f
sm_user_plugin_head: 3b22134 # unchanged by this plan
tech-stack:
added:
- "github.com/pquerna/otp v1.5.0 (totp, hotp), sm-mfa-plugin only"
- "github.com/boombuler/barcode v1.1.0 (qr), sm-mfa-plugin only"
patterns:
- "Guarded single-statement transitions for attempts, completion, TOTP step acceptance, recovery-code consumption and email sends"
- "Per-request effective configuration: config defaults overlaid by a nullable settings singleton"
- "Mail capture in tests through postcard's log driver and a published *slog.Logger"
key-files:
created:
- ../sm-mfa-plugin/plugin.go
- ../sm-mfa-plugin/routes.go
- ../sm-mfa-plugin/buckets.go
- ../sm-mfa-plugin/mail.go
- ../sm-mfa-plugin/admin_settings.go
- ../sm-mfa-plugin/admin_permissions.go
- ../sm-mfa-plugin/config/config.yaml
- ../sm-mfa-plugin/classes/config.go
- ../sm-mfa-plugin/classes/gate.go
- ../sm-mfa-plugin/classes/challenge.go
- ../sm-mfa-plugin/classes/login.go
- ../sm-mfa-plugin/classes/totp.go
- ../sm-mfa-plugin/classes/qr.go
- ../sm-mfa-plugin/classes/recovery.go
- ../sm-mfa-plugin/classes/email.go
- ../sm-mfa-plugin/classes/trusted.go
- ../sm-mfa-plugin/classes/methods.go
- ../sm-mfa-plugin/classes/events.go
- ../sm-mfa-plugin/classes/admin.go
- ../sm-mfa-plugin/controllers/helpers.go
- ../sm-mfa-plugin/controllers/challenge.go
- ../sm-mfa-plugin/controllers/management.go
- ../sm-mfa-plugin/models/ (registry, json_map, string_list, two_factor_method, two_factor_challenge, two_factor_recovery_code, trusted_device, settings, settings/fields.yaml)
- ../sm-mfa-plugin/updates/ (registry and 202610220001..202610220005)
- ../sm-mfa-plugin/views/mail/two_factor_code.htm
- ../sm-mfa-plugin/views/mail/two_factor_code-en.htm
- ../sm-mfa-plugin/testmain_test.go
- ../sm-mfa-plugin/harness_test.go
- ../sm-mfa-plugin/tracer_smoke_test.go
- ../sm-mfa-plugin/challenge_smoke_test.go
- ../sm-mfa-plugin/trusted_settings_smoke_test.go
- ../sm-mfa-plugin/README.md
modified: []
key-decisions:
- "D-07 listener is Listen[*userclasses.AccessRevokedEvent] filtered by ev.PasswordChanged(), deleting trusted devices of ev.UserIDs on ev.Tx (plan 01 has no PasswordChangedEvent)."
- "The failure cap does not count the caller's own in-flight attempt (FailureCapReached inFlight=1 in verify routes, 0 at the gate and email/send), so the 10th failure, not the 9th, closes the window."
- "TOTP step acceptance is a guarded UPDATE inside the completion transaction, so two challenges racing with one code accept it once."
- "sm-mfa-plugin go.mod requires sm-user-plugin v0.1.11 and summercms v0.5.0; go.work replaces the user plugin with ../fonoteka.go/plugins/golem15/user (3b22134). Plan 03 moves the require to the released tag."
- "Migration IDs 202610220001..202610220005 in golem15.mfa's own history table; 202610220006 stays reserved for plan 03's WebAuthn credentials."
patterns-established:
- "Second-factor routes: attempt() counts the attempt, resolves the user and checks the cap; completeSignIn() completes in one transaction, then mints through golem15.user and adds trusted_device_token"
- "Settings overlay: NULL column = config default; stored unknown mode behaves as optional"
requirements-completed: [SC-1, SC-2, SC-4, D-01, D-04, D-05, D-06, D-07, D-09, D-10, D-12, D-13, D-15, D-16, D-18, D-19]
coverage:
- id: D1
description: "TOTP tracer: setup with 200x200 SVG QR (matrix-exact), confirm stores ciphertext, password login answers the challenge with a SHA-256 token column, code completes once, replay refused, PHP 422 shape, 11th request from one IP is 429"
requirement: "SC-2"
verification:
- kind: unit
ref: "../sm-mfa-plugin/tracer_smoke_test.go#TestQRSVGSmoke"
status: pass
- kind: integration
ref: "../sm-mfa-plugin/tracer_smoke_test.go#TestTracerSmoke"
status: pass
human_judgment: false
- id: D2
description: "Recovery codes (first set shown once, bcrypt, single use, count behind password, regenerate), email codes only for enabled users with 3-send cap and capped expiry in the rendered -en mail, five attempts, one token from 20 parallel verifies, per-user failure cap and window, last-method cleanup"
requirement: "D-18"
verification:
- kind: integration
ref: "../sm-mfa-plugin/challenge_smoke_test.go#TestChallengeSmoke (9 subtests)"
status: pass
human_judgment: false
- id: D3
description: "Trusted devices (hash, device name, skip, foreign/expired refused, revoke routes, off by setting), revocation on password change with rollback, settings saved through the admin API applying per request, social toggle, allow-lists, status keys and Carbon dates, published admin reset"
requirement: "D-12"
verification:
- kind: integration
ref: "../sm-mfa-plugin/trusted_settings_smoke_test.go#TestTrustedSettingsSmoke (9 subtests)"
status: pass
human_judgment: false
- id: D4
description: "Settings screen renders and is usable in the backend admin SPA (labels, tabs, dropdown empty choice, taglists)"
requirement: "D-12"
verification:
- kind: integration
ref: "TestTrustedSettingsSmoke/settings_row_applies and allow_lists (PUT /settings/mfa through the admin API)"
status: pass
human_judgment: true
rationale: "The API save path is tested; how the form looks and reads in the SPA is a visual judgement no test asserts."
duration: 25min
completed: 2026-10-26
status: complete
---
# Phase 19.6 Plan 02: sm-mfa-plugin core Summary
**New plugin golem15.mfa (../sm-mfa-plugin, three local commits): a login gate that answers PHP's two_factor_required challenge, TOTP via pquerna/otp with an SVG QR from boombuler/barcode and per-step replay refusal, recovery and email codes with the D-18 hardening, trusted devices revoked on any password change, a per-request settings screen and the TwoFactorAdmin reset service.**
## Performance
- **Duration:** about 25 min
- **Started:** 2026-10-26T09:54:22Z
- **Completed:** 2026-10-26T10:19Z
- **Tasks:** 3 (one tracer, two expansions)
- **Files:** 47 tracked files in the new repository; none changed in summercms.go or sm-user-plugin
## Base commits
- **sm-mfa-plugin** (`../sm-mfa-plugin`): new repository, branch master, no remote. HEAD `8a63f6f`.
- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`): unchanged at `3b22134`.
- **summercms.go**: base `33920ed`; this plan's only commits here are the planning docs.
## Task Commits
| # | Repository | Commit | Message |
|---|------------|--------|---------|
| 1 (tracer) | sm-mfa-plugin | `5c6ad2f` | feat: TOTP second factor through the login gate |
| 2 | sm-mfa-plugin | `43d983b` | feat: recovery codes, email codes and the per-user failure cap |
| 3 | sm-mfa-plugin | `8a63f6f` | feat: trusted devices, the settings screen and the admin reset service |
Nothing was pushed or tagged and no remote was created. The tracer feedback gate (interactive, end-of-phase, automated-only verify) re-ran both verify commands green before Task 2.
## Accomplishments
- **Plugin skeleton (D-01).** `golem15.mfa` requires `golem15.user`. Boot needs no database: it validates config, adds `&classes.Gate{}` to `*userclasses.LoginGates`, publishes `userclasses.TwoFactorAdmin` and registers the password-change listener. Only sm-mfa-plugin's go.mod requires pquerna/otp v1.5.0 and boombuler/barcode v1.1.0, and the archived YAML v3 module is not in `go list -deps`.
- **Challenge (SC-2, D-05, D-06).** The token is 64 characters from crypto/rand and only its SHA-256 hex is stored. TTL is `two_factor_challenge_ttl`. Every verify counts an attempt in one guarded UPDATE (five real attempts, recorded A5), and a second guarded UPDATE completes the challenge, so at most one token is minted. Completion mints through golem15.user (`FinishLogin`, which restores a deactivated account only now, then `IssueToken`, `APIArray` and the login event with source `two_factor`), then fires `golem15.user.2fa.verified`. It honours `golem15.user.jwt.token_cookie`.
- **TOTP (D-04).** Setup answers `{secret, qr_code_svg, qr_code_url}`; the QR is level M with a 4-module quiet zone, 200x200 px with a `viewBox`. Confirm stores the secret as `lagoon.Encrypted` with `last_counter`. Verify uses a one-step window, and accepting a step is a guarded UPDATE, so a code works once.
- **Recovery codes (D-05, D-19).** Sets of 10 `XXXXX-XXXXX` codes are stored as bcrypt and consumed by a guarded UPDATE. The first set is returned by `totp/confirm` and `email/enable`. Listing gives only the count and needs the password; regenerate replaces the set in one transaction.
- **Email codes (D-18 A8, A9).** Six digits, stored as bcrypt and mailed as `golem15.mfa::mail.two_factor_code` (pl) or `-en`. Only users with the email method enabled get one, at most 3 per challenge. The mail promises `min(email TTL, remaining challenge minutes)`.
- **Failure cap (D-18 A6).** 10 failures in 15 minutes per user, counted from challenge rows. While the cap holds, the gate answers 401 `{"error":"Invalid or expired challenge."}` and creates no row, and every challenge route answers its generic 401. Failed rows are kept for the window.
- **Trusted devices (D-10, D-07).** The token is stored as SHA-256 hex, with PHP's "{browser} on {os}" name and a 30-day TTL, off by default. A valid `trusted_device_token` in the login body skips the challenge; social sign-ins carry no fields and are never skipped. Devices are revoked by any password change (inside the write's transaction, so a failure rolls it back), by disabling the last method, and through the two revoke routes.
- **Settings (D-12, D-13, D-09).** Screen `mfa` (permission `golem15.mfa.access_settings`, developer role) has a dropdown mode, array taglists with allow-lists, numbers and switches. A NULL column means the config value, and saved values apply on the next request. Only mode `disabled` exempts users. Social sign-ins are challenged only with `two_factor_social_login` on.
- **Status.** `status` returns PHP's full shape. `trusted_devices` dates use Carbon's raw JSON form, and `webauthn_credentials` stays `[]` until plan 03.
- **Admin reset (D-15).** `classes.Admin.Reset` disables every method and clears its secret and metadata, deletes recovery codes and trusted devices, and fires `golem15.user.2fa.disabled` with method `all`, all on cabana's transaction.
## Exported identifiers, config keys and migration IDs (for plans 03 and 04)
Package `mfa`:
- `PluginID` (`golem15.mfa`), `Plugin`
- `BucketTwoFactorVerify` (`2fa-verify`), `SettingsCode` (`mfa`), `PermissionAccessSettings` (`golem15.mfa.access_settings`)
Package `classes`:
- Config:
- `Config`, `LoadConfig`, `Effective` (struct embedding `Config`; `MethodAvailable`, `ChallengeDuration`), `LoadEffective(ctx, app, db)`
- `ConfigPrefix`, `KnownMethods`, `KnownModes`, `DefaultAvailableMethods`, `DefaultMode`, `DefaultEmailCodeTTL`, `DefaultChallengeTTL`, `DefaultTrustedDeviceTTLDays`, `DefaultFailureCapMax`, `DefaultFailureCapWindow`, `DefaultIssuer`
- Gate: `Gate{App}`, `Database(app)`, `ClientIP(app, r)`
- Challenge:
- `NewChallengeToken`, `HashToken`
- `CreateChallenge(ctx, db, user, ip, ua, ttl, keep)`, `FindChallenge`, `BeginAttempt`, `CompleteChallenge`
- `FailureCount`, `FailureCapReached(ctx, db, eff, userID, now, inFlight)`
- `ErrInvalidChallenge`, `ChallengeTokenLength`, `MaxAttempts`
- TOTP and QR:
- `NewTOTPSecret`, `TOTPURL`, `NormalizeTOTPSecret`, `TOTPCounter`, `VerifyTOTP`, `TOTPPeriod`, `TOTPWindow`
- `QRSVG(text, size)`
- Recovery codes:
- `RecoveryCodeAlphabet`, `RecoveryCodeCount`, `MaxRecoveryCodeLength`, `BcryptCost`
- `NewRecoveryCode`, `GenerateRecoveryCodes`, `EnsureRecoveryCodes`, `NormalizeRecoveryCode`
- `MatchRecoveryCode`, `MarkRecoveryCodeUsed`, `ConsumeRecoveryCode`, `RemainingRecoveryCodes`, `DeleteRecoveryCodes`
- Email codes:
- `EmailCodeTemplate`, `MaxEmailSends`
- `NewEmailCode`, `MaskEmail`, `SendEmailCode(ctx, app, db, eff, ch, user)`, `EmailCodeMinutes`, `VerifyEmailCode`
- Trusted devices:
- `UnknownDevice`, `ParseDeviceName`
- `IssueTrustedDevice`, `CheckTrustedDevice`, `RevokeTrustedDevices(ctx, tx, userIDs...)`, `RevokeTrustedDevice`, `TrustedDeviceList`
- Methods and status:
- `EnabledMethods`, `MethodRow`, `MethodEnabled`, `EnableMethod`, `DisableMethod(ctx, app, tx, user, method)`
- `AcceptTOTPCounter`, `TouchMethod`, `LastCounter`
- `Enabled`, `Enforced`, `Status`
- Login: `CompleteLogin`, `ChallengeUser`
- Admin and listener: `Admin` (TwoFactorAdmin), `OnAccessRevoked(app)`
- Events:
- `ChallengedEvent`, `FailedEvent`, `VerifiedEvent`, `RecoveryUsedEvent`, `EnabledEvent`, `DisabledEvent`, `RecoveryRegeneratedEvent`
- Names: `ChallengedEventName`, `FailedEventName`, `RecoveryUsedEventName`, `VerifiedEventName`, `EnabledEventName`, `DisabledEventName`, `RecoveryRegeneratedEventName`
- `MethodAll`, `Fire`
Package `controllers`:
- Challenge routes: `Verify`, `Recovery`, `EmailSend`
- Management routes: `Status`, `TOTPSetup`, `TOTPConfirm`, `TOTPDisable`, `EmailEnable`, `EmailDisable`, `RecoveryCodesCount`, `RecoveryCodesRegenerate`, `TrustedDeviceRevoke`, `TrustedDevicesRevokeAll`
- `QRSize`
- Unexported helpers plan 03 reuses inside the package: `attempt`, `completeSignIn`, `withTrustedDevice`, `signedIn`, `validate`
Package `models`:
- `TwoFactorMethod`, `TwoFactorChallenge`, `TwoFactorRecoveryCode`, `TrustedDevice`, `Settings`, `LoadSettings`, `SettingsMethods`, `StringList`, `JSONMap`
- `MethodTOTP`, `MethodEmail`, `MethodWebAuthn`, `MethodRecovery`, `ModeDisabled`, `ModeOptional`, `ModeEnforced`, `LangPrefix`
Config keys (`golem15.mfa.*`, env `SUMMER_GOLEM15__MFA__...`):
- `two_factor_mode` (optional), `two_factor_available_methods` ([totp, email]), `two_factor_enforce_groups` ([])
- `two_factor_email_code_ttl` (10), `two_factor_challenge_ttl` (5)
- `two_factor_passwordless_login` (false)
- `trusted_device_enabled` (false), `trusted_device_ttl_days` (30)
- `two_factor_social_login` (false)
- `issuer` (app.name)
- `failure_cap.max` (10), `failure_cap.window_minutes` (15)
Migrations (golem15.mfa history):
- `202610220001_create_golem15_user_two_factor_methods`
- `202610220002_create_golem15_user_two_factor_challenges`
- `202610220003_create_golem15_user_two_factor_recovery_codes`
- `202610220004_create_golem15_user_trusted_devices`
- `202610220005_create_golem15_mfa_settings`
- Plan 03 takes `202610220006` for WebAuthn credentials.
Routes:
- Challenge (`throttle:2fa-verify`): `POST /_user/api/v1/2fa/{verify,recovery,email/send}`
- Management (`throttle:user-api` plus `jwt.auth` per route): `GET status`, `POST totp/setup`, `POST totp/confirm`, `DELETE totp`, `POST email/enable`, `DELETE email`, `GET recovery-codes`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}` (digits), `DELETE trusted-devices`
Mail templates: `golem15.mfa::mail.two_factor_code`, `golem15.mfa::mail.two_factor_code-en`.
## Tests
All Docker-backed tests ran against testcontainers Postgres. None were NOT RUN.
- `go vet ./...` and `go test -count=1 -short ./...`: green at every commit.
- `TestQRSVGSmoke` (short): pass.
- `TestTracerSmoke`: pass.
- `TestChallengeSmoke`, 9 subtests, all pass: `recovery_first_set`, `recovery_once`, `regenerate`, `email_only_when_enabled`, `email_send_cap`, `five_attempts`, `parallel_verify`, `failure_cap`, `disable_last_method`.
- `TestTrustedSettingsSmoke`, 9 subtests, all pass: `trusted_skip`, `revoke_routes` (added), `trusted_disabled`, `password_change_revokes`, `settings_row_applies`, `social_toggle`, `allow_lists`, `status_shape`, `admin_reset`.
- Full `go test -count=1 ./...` on the final commit: ok.
## Decisions Made
See key-decisions in the frontmatter. In addition:
- `Effective` is a struct embedding `Config`, so plan 03 can add fields without changing `LoadEffective`'s signature.
- The mode dropdown's empty choice, "Server configuration", stores NULL.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] The parent directory is not writable by golem**
- **Found during:** Task 1 (`git init ../sm-mfa-plugin`)
- **Issue:** `summercms.io/summercms` is owned by `jin` (0775, golem not in the group).
- **Fix:** Created only `sm-mfa-plugin` with `sudo -n install -d -o golem -g golem -m 0775`, then ran `git init -b master` as golem. The golem-owned siblings sm-oauthserver-plugin and sm-journal-plugin are the precedent. Nothing else was created or changed with sudo.
**2. [Plan 01 carry-forward - D-07] Listener on AccessRevokedEvent, not PasswordChangedEvent**
- **Found during:** Task 3
- **Fix:** `app.Events.Listen[*userclasses.AccessRevokedEvent](PluginID, classes.OnAccessRevoked(app))`, which skips events where `PasswordChanged()` is false and deletes the trusted devices of `ev.UserIDs` on `ev.Tx`.
- **Acceptance impact:** The Task 3 grep for `Listen[*userclasses.PasswordChangedEvent]` prints 0 by design. `TestTrustedSettingsSmoke/password_change_revokes` proves D-07, including the rollback.
- **Commit:** `8a63f6f`
**3. [Rule 3 - Blocking] go.mod versions**
- **Issue:** The plan named sm-user-plugin v0.1.5, which predates every seam.
- **Fix:** go.mod requires `sm-user-plugin v0.1.11` (the phase base) and `summercms v0.5.0` (what sm-user-plugin requires), with go.mod `replace summercms => ../summercms.go` as sociallogin has. go.work replaces both with the local checkouts. Plan 03 bumps the user plugin to the released tag.
- **Commit:** `5c6ad2f`
**4. [Rule 1 - Race] TOTP replay guarded atomically**
- **Issue:** The plan stored `last_counter` in the completion transaction. Two challenges verifying the same code in parallel could both read the old counter.
- **Fix:** `AcceptTOTPCounter` is a guarded `UPDATE … WHERE last_counter < ?` in the completion transaction. Losing it rolls the completion back and answers the failure 401.
- **Commit:** `5c6ad2f`
**5. [Rule 2 - Security] Availability check on totp/confirm**
- **Issue:** PHP checked availability only on setup, so a self-made secret bypassed the 403.
- **Fix:** `totp/confirm` also answers 403 `{"error":"TOTP is not available."}`.
- **Commit:** `5c6ad2f`
**6. [Rule 2 - Correctness] Completed challenges honour token_cookie**
- **Fix:** A completed challenge honours `golem15.user.jwt.token_cookie` as `/login` does: the cookie is set and `token` is left out of the body.
- **Commit:** `5c6ad2f`
**7. [Rule 2 - Correctness] The gate needs a usable method**
- **Issue:** PHP challenged on any enabled row, including webauthn without credentials, which gives a challenge nobody can answer.
- **Fix:** The gate challenges only when the filtered method list is non-empty. `status.enabled` and `status.methods` keep PHP's raw-row semantics.
- **Commit:** `5c6ad2f`, `8a63f6f`
**8. [Rule 1 - Correctness] The failure cap ignores the in-flight attempt**
- **Fix:** `FailureCapReached(ctx, db, eff, userID, now, inFlight)`. The verify routes pass 1, because their own just-counted attempt is not yet a failure; the gate and email/send pass 0. Without this, a correct code after 9 failures would be refused.
- **Signature changes:** `CreateChallenge` gained `keep` (the prune horizon). `SendEmailCode` takes `eff`.
- **Recovery split:** `MatchRecoveryCode` (the bcrypt compares, outside the transaction) and `MarkRecoveryCodeUsed` (guarded, inside the completion transaction); `ConsumeRecoveryCode` is kept as the combined helper. Long bcrypt loops therefore hold no row lock.
- **Commit:** `43d983b`
**9. [Rule 1 - Bug] Carbon dates at second precision**
- **Issue:** Postgres keeps microseconds, while PHP's columns had none.
- **Fix:** `trusted_devices` dates are truncated to the second, so they read `….000000Z`.
- **Commit:** `8a63f6f`
**10. [Rule 3 - Test harness] Mail capture through the log driver**
- **Issue:** party.Activate publishes `postcard.Mailer`, and it cannot be published twice.
- **Fix:** The harness boots with `mail.driver: log` and publishes a capturing `*slog.Logger`, so tests read the rendered subject and text. This also checks template rendering.
- **Other harness changes:** `bootMFA` returns `*mfaEnv` rather than `(app, server)`, and the harness sets `golem15.user.password.bcrypt_cost: 4`.
**11. [Scope] Admin.HasTwoFactor is broader**
- **Fix:** It is true for an enabled method, a recovery code or a trusted device, as the user plugin's `TwoFactorAdmin` doc defines it. The plan said only an enabled method.
**12. [Scope] No `golem15.mfa::lang.mail.*` keys**
- **Reason:** The templates carry their subject and body, as golem15.user's mails do, so mail lang keys would be unused. The `settings.*`, `permissions.*` and `plugin.*` keys exist in en and pl.
**13. [Rule 2 - Hardening] Smaller additions**
- The email-send guarded UPDATE also requires `attempts < max_attempts`.
- A challenge or trusted token that is not 64 characters is refused before any query.
- Tests added beyond the plan: `revoke_routes`, the 422 shape check in the tracer, and the settings save through the admin API.
---
**Total deviations:** 13: environment 1, carry-forward 1, versions 1, race and security fixes 4, correctness 3, harness 1, scope 2.
**Impact on plan:** Every must-have truth holds. Only the D-07 listener grep fails, and it fails by design; no locked CONTEXT decision changed.
## Known Stubs
- `classes/methods.go` `Status`: `webauthn_credentials` is always `[]`, and `EnabledMethods` never lists `webauthn`. Plan 03 adds the credential table (migration 202610220006) and fills both in. Intentional.
- `classes/admin.go` `Reset`: it does not yet delete WebAuthn credentials, because the table does not exist yet. Plan 03 adds that deletion.
## Threat Flags
None. Every new surface (challenge and management routes, settings screen, reset service, password-change listener) is in the plan's threat register.
## Issues Encountered
None beyond Deviation 1.
## g15office
This executor has no g15office tool, so it posted no comment on milestone task `avoroeb4`. The orchestrator should post the plan 02 update.
## User Setup Required
None.
## Next Phase Readiness
- Plan 03 can add WebAuthn inside sm-mfa-plugin:
- Migration `202610220006`.
- The webauthn verify and options routes in the challenge group, reusing `attempt`, `completeSignIn` and `withTrustedDevice`.
- `status.webauthn_credentials`, the `EnabledMethods` credential check, and WebAuthn deletion in `Admin.Reset`.
- The passwordless routes, gated by `two_factor_passwordless_login`.
- Plan 03 still needs the user's go-ahead to create the remote and push sm-mfa-plugin and sm-user-plugin.
- STATE.md already held uncommitted 19.6 hunks from another session or the orchestrator when this plan started. This plan ran the state handlers on top of them, but committed only SUMMARY.md and ROADMAP.md.
## Self-Check: PASSED
- All key files exist in ../sm-mfa-plugin.
- Commits 5c6ad2f, 43d983b and 8a63f6f exist there; the working tree is clean and there is no remote.
- The final full test run was green.

View File

@@ -0,0 +1,572 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
plan: 03
subsystem: auth
tags: [sm-mfa-plugin, webauthn, go-webauthn, passkeys, passwordless, sm-sociallogin-plugin, login-gate, vue-demo-app, totp-ui, release, sm-demo-app, sm-bm-app]
requires:
- phase: 19.6-01
provides: sm-user-plugin login gates (CheckLoginGates, LoginSourceSocial, LoginSourcePasskey), FinishLogin/IssueToken/APIArray/FireLoginEvent, RejectIfThrottled, ThrottleStates
- phase: 19.6-02
provides: sm-mfa-plugin core (challenge store, attempt/completeSignIn/withTrustedDevice, EnableMethod/DisableMethod, EnsureRecoveryCodes, Status, Admin, migration slot 202610220006)
provides:
- WebAuthn security keys as a second factor in sm-mfa-plugin (go-webauthn v0.18.2, standard base64url shapes, D-08)
- golem15_user_webauthn_credentials (migration 202610220006) with credential_data and passwordless
- Passwordless passkey sign-in behind two_factor_passwordless_login (D-14)
- D-09 social-login gate in sm-sociallogin-plugin v0.1.4
- Demo two-factor UI in vue-demo-app (TOTP setup, recovery codes, disable, login challenge; D-20), pushed
- Releases: sm-user-plugin v0.1.12, sm-mfa-plugin v0.1.0 (new private repository), sm-sociallogin-plugin v0.1.4
- sm-demo-app mounts golem15.mfa v0.1.0 at user v0.1.12, sociallogin v0.1.4 and the two-factor UI, pushed (not deployed)
- sm-bm-app mounts golem15.mfa (API only) at user v0.1.12 and sociallogin v0.1.4, pushed
- fonoteka.go at sm-user-plugin v0.1.12 with its guard tests updated (local commit)
affects: [19.6-04, sm-demo-app, sm-bm-app, fonoteka.go, vue-demo-app, sm-sociallogin-plugin]
actuals:
tokens: 38817 # MEASURED chars/4 over added lines: 37360 at the draft (Tasks 1-3, 33ad164, b752177, sm-bm-app aafbcce..ebb2266, fonoteka.go 06bc9d0..95fd58a) plus sm-demo-app 40ed518..bb24207 (5827 chars); go.sum and go.work.sum excluded
tasks: 6
commits: 2 # MEASURED in summercms.go before this final commit: git rev-list --count 5d78f53..HEAD (5a64611, 4002bea); this SUMMARY commit and the ROADMAP commit follow. Code lives in other repositories (below)
sm_mfa_plugin_commits: 3 # MEASURED: git -C ../sm-mfa-plugin rev-list --count 8a63f6f..HEAD
sm_sociallogin_plugin_commits: 2 # MEASURED: git -C ../sm-sociallogin-plugin rev-list --count 4d3b2dd..HEAD
vue_demo_app_commits: 1 # MEASURED: git -C ../sm-demo-app/vue-demo-app rev-list --count 9c247ab..HEAD
sm_bm_app_commits: 3 # MEASURED: git -C ../sm-bm-app rev-list --count aafbcce..HEAD
fonoteka_go_commits: 1 # MEASURED: git -C ../fonoteka.go rev-list --count 06bc9d0..HEAD
sm_demo_app_commits: 4 # MEASURED: git -C ../sm-demo-app rev-list --count 40ed518..bb24207
plan_head_before: 5d78f53ddc890ef53fe52da1828f3f88d9923270
plan_head_after: 4002bea
sm_mfa_plugin_head_before: 8a63f6f
sm_mfa_plugin_head_after: 33ad164
sm_sociallogin_plugin_head_before: 4d3b2dd
sm_sociallogin_plugin_head_after: b752177
vue_demo_app_head_before: 9c247ab
vue_demo_app_head_after: 44a5229
sm_bm_app_head_before: aafbcce
sm_bm_app_head_after: ebb2266
fonoteka_go_head_before: 06bc9d0
fonoteka_go_head_after: 95fd58a
sm_demo_app_head_before: 40ed518
sm_demo_app_head_after: bb24207
sm_user_plugin_head: 3b22134 # tagged v0.1.12 and pushed
tech-stack:
added:
- "github.com/go-webauthn/webauthn v0.18.2 (packages webauthn, protocol), sm-mfa-plugin only (approved 2026-10-26); brings fxamacker/cbor/v2, go-webauthn/x, google/go-tpm, tinylib/msgp as indirect modules (also indirect in sm-bm-app and sm-demo-app through the mount)"
patterns:
- "WebAuthn ceremony state server-side: registration and passkey state in cooler consumed with Pull; login state on the challenge row"
- "Pure verification helpers (VerifyRegistration, VerifyAssertion) shared by handlers and a no-database smoke test"
- "Virtual authenticator test helper (ECDSA P-256, attestation none, CBOR through go-webauthn's webauthncbor)"
- "Application mount test without a database: route table entry with the owning plugin, a compiled handler, and an anonymous request refused by jwt.auth (TestMFAMounted in sm-bm-app and sm-demo-app)"
key-files:
created:
- ../sm-mfa-plugin/classes/webauthn.go
- ../sm-mfa-plugin/classes/passwordless.go
- ../sm-mfa-plugin/controllers/webauthn.go
- ../sm-mfa-plugin/controllers/passwordless.go
- ../sm-mfa-plugin/models/webauthn_credential.go
- ../sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go
- ../sm-mfa-plugin/webauthn_authenticator_test.go
- ../sm-mfa-plugin/webauthn_smoke_test.go
- ../sm-mfa-plugin/passwordless_smoke_test.go
- ../sm-mfa-plugin/go.work.sum
- ../sm-sociallogin-plugin/social_gate_smoke_test.go
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.ts
- ../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts
- ../sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts
- ../sm-demo-app/vue-demo-app/app/pages/account/security.vue
- ../sm-demo-app/mfa_test.go
- ../sm-demo-app/config/golem15.mfa.yaml
- ../sm-demo-app/plugins/golem15/mfa (submodule)
- ../sm-bm-app/mfa_test.go
- ../sm-bm-app/plugins/golem15/mfa (submodule)
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-sociallogin-gate.patch
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/deferred-items.md
- .planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md
modified:
- ../sm-mfa-plugin/{go.mod,go.sum,routes.go,plugin.go,README.md,config/config.yaml,lang/en/lang.yaml,lang/pl/lang.yaml}
- ../sm-mfa-plugin/classes/{config.go,methods.go,admin.go,events.go,login.go}
- ../sm-sociallogin-plugin/{classes/flow.go,classes/codes.go,controllers/callback.go,controllers/complete.go,controllers/register_complete.go,lang/en/lang.yaml,lang/pl/lang.yaml,README.md,go.mod,go.sum}
- ../sm-demo-app/vue-demo-app/app/{composables/useDemoAuth.ts,pages/login.vue,components/SiteHeader.vue}
- ../sm-demo-app/vue-demo-app/i18n/locales/{en.json,pl.json}
- ../sm-demo-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,boot_test.go,deploy/env.example,README.md,plugins/golem15/user,plugins/golem15/sociallogin,vue-demo-app}
- ../sm-bm-app/{.gitmodules,go.mod,go.sum,go.work,summer.yaml,plugins.gen.go,scripts/build.sh,README.md,plugins/golem15/user,plugins/golem15/sociallogin}
- ../fonoteka.go/{plugins/golem15/user,parity/migrate_test.go,parity/schema_diff_test.go,plugins/golem15/fonoteka/classes/hidden_marshal_test.go,plugins/golem15/fonoteka/phase07_coverage_test.go,plugins/golem15/fonoteka/routes_isolation_test.go}
key-decisions:
- "WebAuthn RP from golem15.mfa.webauthn.rp_id / rp_name / origins (defaults: app.url host, app.name, app.url and golem15.user.frontend_url origins); an IP host or no origin makes WebAuthnFor answer ErrWebAuthnUnavailable, and the routes answer 403 'WebAuthn is not available.'"
- "Assertion verification is split: FinishAssertion verifies outside the transaction, RecordAssertion writes the record back in the completing transaction with a guarded UPDATE on the old sign_count (a lost race refuses the login)"
- "Registration answers 'Registration failed: <reason>' with fixed reasons: invalid or expired challenge, invalid response, verification failed, credential already registered"
- "Passkey routes also answer 403 while two_factor_mode is disabled (fail closed), not only while two_factor_passwordless_login is off"
- "Passkey sign-in refuses a ban or suspension from any address (ThrottleStates) and from the request's address (RejectIfThrottled)"
- "A challenged social sign-in does not fire SignedInEvent and does not restore a deactivated account; the identity is stored before the gate"
- "No framework tag: sm-mfa-plugin and sm-sociallogin-plugin pass against the framework's v0.5.0 tag; sociallogin's require rose from v0.3.1 to v0.5.0 through go mod tidy"
- "Release tags: sm-user-plugin v0.1.12, sm-mfa-plugin v0.1.0, sm-sociallogin-plugin v0.1.4 (the plan's v0.1.6 and v0.1.3 were stale)"
- "fonoteka.go guard tests follow the user plugin across every version the bump crosses (v0.1.10 to v0.1.12), not only this phase's additions, because they were already red at the committed v0.1.10 pointer"
- "sm-demo-app's golem15.mfa config: two_factor_mode optional, methods [totp, email], social sign-ins unchallenged (the demo's OAuth callback page does not handle two_factor_required), webauthn not offered (no demo screens); RP defaults to the app.url host, which is demo.summercms.io in production"
patterns-established:
- "Plugin work in a checkout the executor cannot write: implement and test in a scratch clone with a GOWORK file, export git format-patch, keep the patch in the phase directory, apply with git am once access is granted and verify the tree hash matches"
requirements-completed: [SC-1, SC-2, SC-4, D-01, D-02, D-04, D-05, D-06, D-08, D-09, D-10, D-14, D-16, D-17, D-19, D-20]
coverage:
- id: D1
description: "WebAuthn second factor: register options (attestation none), register with PHP's answer and first recovery codes, single-use registration state, status list, login offering webauthn, verify before options refused, assertion completing the challenge with the sign count written back, clone refused, last key removal disabling the method, admin reset deleting keys, 403 while unavailable"
requirement: "SC-1"
verification:
- kind: unit
ref: "../sm-mfa-plugin/webauthn_smoke_test.go#TestWebAuthnCeremonySmoke"
status: pass
- kind: integration
ref: "../sm-mfa-plugin/webauthn_smoke_test.go#TestWebAuthnSmoke"
status: pass
human_judgment: false
- id: D2
description: "Passwordless passkey sign-in: 403 while off, passkey signs in with one passkey login event, second-factor key / no credProps / no UV / foreign handle / banned / unactivated / reused challenge refused with one 401"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-mfa-plugin/passwordless_smoke_test.go#TestPasswordlessSmoke (7 subtests)"
status: pass
human_judgment: false
- id: D3
description: "Social sign-in through the login gate (sm-sociallogin-plugin a8e3e78, released as v0.1.4)"
requirement: "D-09"
verification:
- kind: integration
ref: "../sm-sociallogin-plugin/social_gate_smoke_test.go#TestSocialGateSmoke (5 subtests) and TestTracerSmoke, in the real checkout"
status: pass
human_judgment: false
- id: D4
description: "Demo two-factor UI: TOTP setup with the QR image, recovery codes shown once, turn off, login challenge with TOTP or recovery code"
requirement: "D-20"
verification:
- kind: unit
ref: "../sm-demo-app/vue-demo-app/app/lib/twoFactor.test.ts (14 tests)"
status: pass
- kind: other
ref: "pnpm build; server render of /account/security and /login on the built output (200)"
status: pass
- kind: integration
ref: "../sm-demo-app/mfa_test.go#TestMFAMounted (the API the screens call is mounted in the demo)"
status: pass
human_judgment: true
rationale: "Look and flow in a browser against a running demo with golem15.mfa mounted are a visual judgement; no browser run was made and the demo is not deployed."
- id: D5
description: "D-17 adoption: plugins published in order; demo and bm mount mfa; fonoteka.go green at the new pointer without mfa"
requirement: "D-17"
verification:
- kind: other
ref: "ls-remote of v0.1.12, v0.1.0, v0.1.4 and the pushed branches; sm-bm-app TestMFAMounted; sm-demo-app TestMFAMounted, TestBootAllPlugins, TestGoModRequiresTags and the full suite; fonoteka.go vet, -short suites and build"
status: pass
human_judgment: false
duration: about 100 min of wall clock (2026-10-26 11:08Z to 12:50Z), including two checkpoint waits
completed: 2026-10-26
status: complete
---
# Phase 19.6 Plan 03: WebAuthn and adoption Summary
**WebAuthn second factor and passwordless passkeys in sm-mfa-plugin v0.1.0 (go-webauthn v0.18.2, ceremony state server-side), the social-login gate in sm-sociallogin-plugin v0.1.4, the demo's TOTP screens, and adoption in sm-demo-app, sm-bm-app (both pushed) and fonoteka.go (local).**
**Status:** complete. All 6 tasks are done.
- **sm-demo-app (Task 6 part 1):** commits A-D made and pushed, after the second checkpoint was resolved (see "Task 6 sm-demo-app reply").
- **sm-bm-app (part 2):** pushed.
- **fonoteka.go (part 3):** local commit only.
Nothing was deployed.
**Results:**
- **sm-mfa-plugin v0.1.0:** WebAuthn security keys as a second factor and passwordless passkey sign-in behind its setting. The new private repository is https://git.golem15.com/golem15/sm-mfa-plugin.
- **sm-sociallogin-plugin v0.1.4:** social sign-in goes through the login gate (D-09).
- **sm-user-plugin v0.1.12:** plan 01's login gates and device authorization, published.
- **vue-demo-app:** the TOTP setup, recovery-code and login-challenge screens are pushed.
- **sm-demo-app:** mounts golem15.mfa v0.1.0 with user v0.1.12, sociallogin v0.1.4 and the two-factor UI. Pushed, not deployed.
- **sm-bm-app:** mounts golem15.mfa (API only) with user v0.1.12 and sociallogin v0.1.4. Pushed.
- **fonoteka.go:** at user v0.1.12, guard tests updated. Local commit only.
## Performance
- **Started:** about 2026-10-26T11:08Z
- **First checkpoint (Task 4):** 11:48Z
- **First continuation:** about 11:55Z to 12:25Z, ending at the second checkpoint (sm-demo-app)
- **Second continuation:** about 12:30Z to 12:50Z
- **Tasks:** 6 of 6
## Task 4 reply
Verbatim: "1. release 2. push demo 3. you can sudo chown golem:golem sm-sociallogin-plugin if needed."
The orchestrator's interpretation, recorded as given:
1. **release**, with the proposed tags:
- sm-user-plugin v0.1.12
- sm-mfa-plugin v0.1.0, in a new private repository golem15/sm-mfa-plugin created with `tea repos create --owner golem15 --name sm-mfa-plugin`
- sm-sociallogin-plugin v0.1.4
2. **"push demo"**:
- Pushing vue-demo-app is authorised, including the 4 unpushed Phase 19.7 commits (`0125e29`, `b973a76`, `3c53182`, `9c247ab`) under `44a5229`.
- Pushing sm-demo-app after Task 6's commits is authorised, including its 19 unpushed commits from other phases.
- sm-bm-app may be pushed after Task 6 only if its unpushed range is this phase's commits.
- Never stage another session's uncommitted edits.
3. **Ownership:** the orchestrator ran `sudo chown -R golem:golem` on `sm-sociallogin-plugin`. No other sudo.
Further limits:
- **Plan 04 patch tags:** not authorised. Plan 04 must ask again before any tag.
- Nothing is force-pushed or deployed.
- fonoteka.go is never pushed.
- No framework tag and no changelog post.
## Task 6 sm-demo-app reply
**Context:** the first continuation stopped before Task 6 part (1). sm-demo-app had uncommitted submodule pointer moves:
- journal `4ab94da`→`b129902`
- oauthserver `8bbec72`→`b6a9586`
- user `bfcfea4`→`50da92c` (v0.1.11)
- vue-demo-app `9c247ab`→`44a5229` (this phase's)
Commit A has to move that same user pointer. The orchestrator investigated: the moves are leftover Phase 19.7 checkouts (plan 05, 2026-10-23) that were never committed into sm-demo-app, not a live session.
The user's reply, verbatim: "proceed"
The orchestrator's interpretation, recorded as given:
- Move only the demo's `plugins/golem15/user` checkout from `50da92c` (v0.1.11) to v0.1.12 (`3b22134`), and make commits A-D as the plan specifies:
- A: user v0.1.12
- B: sociallogin v0.1.4
- C: mount golem15.mfa at v0.1.0 as the submodule `plugins/golem15/mfa`
- D: the vue-demo-app pointer to `44a5229`
- Leave the journal and oauthserver moves unstaged and untouched.
- Run the demo verify (`TestMFAMounted`, `TestBootAllPlugins`, `TestGoModRequiresTags`, plus vet, `-short` and the full suite as the plan says).
- Push sm-demo-app master. The authorised range is the 19 pre-existing unpushed commits (`a25444e..40ed518`, all dated 2026-10-23) plus A-D. Fetch first and verify the unpushed range is exactly those; if origin moved or anything else appears, stop and return a checkpoint.
- No force-push, no deploy. Plan 04 patch tags remain not authorised.
**Outcome:**
- **User checkout:** fast-forwarded on its `master` branch from `50da92c` to `3b22134`. `50da92c` is an ancestor and is on origin, and the submodule tree was clean.
- **journal and oauthserver:** left as they were (still ` M`). They are tracked as a pending todo.
## Commits per repository
| Task | Repository | Commit | Message |
|---|---|---|---|
| 1 (tracer) | sm-mfa-plugin | `2662252` | feat: WebAuthn security keys as a second factor |
| 2 | sm-mfa-plugin | `6c8916f` | feat: passwordless passkey login |
| 2 | sm-sociallogin-plugin | `a8e3e78` | feat: social sign-in through the login gate (patch applied with `git am`; tree `c5f28f3` identical to the scratch clone's `a0161d7`) |
| 3 | vue-demo-app | `44a5229` | feat: two-factor setup and the login challenge |
| 5 | sm-mfa-plugin | `33ad164` | chore: require sm-user-plugin v0.1.12 |
| 5 | sm-sociallogin-plugin | `b752177` | chore: require sm-user-plugin v0.1.12 |
| 6 (1) A | sm-demo-app | `0eb1f60` | chore: bump sm-user-plugin to v0.1.12 and require summercms v0.5.0 |
| 6 (1) B | sm-demo-app | `1c43e29` | chore: bump sm-sociallogin-plugin to v0.1.4 |
| 6 (1) C | sm-demo-app | `2e5b7da` | feat: mount golem15.mfa |
| 6 (1) D | sm-demo-app | `bb24207` | chore: bump vue-demo-app to the two-factor UI |
| 6 (2) | sm-bm-app | `aad6004` | chore: bump sm-user-plugin to v0.1.12 |
| 6 (2) | sm-bm-app | `376cff4` | chore: bump sm-sociallogin-plugin to v0.1.4 |
| 6 (2) | sm-bm-app | `ebb2266` | feat: mount golem15.mfa |
| 6 (3) | fonoteka.go (local only) | `95fd58a` | chore: bump sm-user-plugin to v0.1.12 |
| draft | summercms.go | `5a64611` | docs(19.6-03): draft summary at the Task 4 release checkpoint |
| draft | summercms.go | `4002bea` | docs(19.6-03): release and adoption up to the sm-demo-app checkpoint |
What commit C (`2e5b7da`) changes:
- **Mount:**
- `.gitmodules`, with the submodule `plugins/golem15/mfa` at v0.1.0 (`33ad164`)
- `go.work`: `use ./plugins/golem15/mfa`
- `summer.yaml`: `golem15.mfa` after `golem15.sociallogin`
- `go.mod`: require v0.1.0, tidied
- `plugins.gen.go`: regenerated by `summer build`, the only file it changed (main.go was unchanged)
- **Config and docs:**
- `config/golem15.mfa.yaml`
- `deploy/env.example`, with commented `SUMMER_GOLEM15__MFA__` lines
- the README plugin row
- **Tests:**
- `boot_test.go`: `wantPlugins` and the `/_user/api/v1/2fa/` and `/_user/api/v1/devices` prefixes
- new `mfa_test.go`
## Remote operations (in order)
Before every push I ran `git fetch` and checked that `origin/master..master` held only the authorised commits. Nothing was force-pushed or deployed.
1. **sm-user-plugin** (`git@git.golem15.com:golem15/sm-user-plugin.git`):
- Pushed master `50da92c..3b22134`: `8501f34`, `7900f47`, `3d4c8d2`, `4e3e5b0`, `3b22134`.
- Annotated tag **v0.1.12** on `3b22134` (tag object `c0e0e85`), pushed.
2. **sm-mfa-plugin:**
- Created the private repository **https://git.golem15.com/golem15/sm-mfa-plugin** with `tea repos create --login git.golem15.com --owner golem15 --name sm-mfa-plugin --private`. The API confirms `"private":true`.
- `git push -u origin master` pushed the new branch at `33ad164`, 6 commits.
- Annotated tag **v0.1.0** on `33ad164` (tag object `b84dcbe`), pushed.
3. **sm-sociallogin-plugin:**
- Pushed master `4d3b2dd..b752177`: `a8e3e78`, `b752177`.
- Annotated tag **v0.1.4** on `b752177` (tag object `18af090`), pushed.
4. **vue-demo-app:** pushed master `21a22b6..44a5229`. That is the 4 named Phase 19.7 commits plus `44a5229`. No tag.
5. **sm-bm-app** (Task 6 part 2): pushed master `aafbcce..ebb2266`, this phase's 3 commits only.
6. **sm-demo-app** (`git@git.golem15.com:golem15/sm-demo-app.git`, Task 6 part 1): pushed master **`18597e5..bb24207`**, 23 commits. No tag.
- **Pre-push checks:**
- After `git fetch`, origin/master was still `18597e5` and `master..origin/master` was empty.
- `origin/master..master` was exactly the 19 authorised commits (`a25444e` … `40ed518`, all dated 2026-10-23) plus A-D (`0eb1f60`, `1c43e29`, `2e5b7da`, `bb24207`).
- Every gitlink those 23 commits introduce is on its submodule's origin: vue-demo-app `0125e29`, `21a22b6`, `3c53182`, `44a5229`, `9c247ab`, `b973a76`; user `3828a65`, `bfcfea4`, `3b22134`; journal `4ab94da`; oauthserver `116df7f`, `8bbec72`; sociallogin `b752177`; mfa `33ad164`.
- **After the push:** `origin/master..master` is empty and `ls-remote` shows `bb24207`.
Read-only fetches also ran in the demo's user, sociallogin, mfa, journal and oauthserver submodules and in vue-demo-app.
The acceptance checks pass:
- `ls-remote --exit-code --tags` for v0.1.12, v0.1.0 and v0.1.4.
- `grep -c 'sm-user-plugin v0.1.12' ../sm-mfa-plugin/go.mod` = 1.
- `origin/master..master` is empty for sm-user-plugin, vue-demo-app, sm-bm-app and sm-demo-app.
- **sm-demo-app greps:**
- `grep -c 'id: golem15.mfa' summer.yaml` = 1
- `grep -c 'git.golem15.com/golem15/sm-mfa-plugin v0.1.0' go.mod` = 1
- `grep -c 'golem15.mfa' plugins.gen.go` = 1
## Tests
All Docker-backed tests ran against testcontainers Postgres (Docker reachable as golem).
- **sm-mfa-plugin:**
- **Tasks 1-2:** `go vet ./...` and `-short`: green at every commit.
- `TestWebAuthnCeremonySmoke`: pass.
- `TestWebAuthnSmoke`, `TestTracerSmoke`, `TestChallengeSmoke`, `TestTrustedSettingsSmoke`: pass.
- `TestPasswordlessSmoke` (7 subtests): pass.
- Full suite: ok, also against the framework's v0.5.0 tag.
- **Task 5, after the require bump:**
- Vet and `-short` in workspace mode: green.
- With `GOWORK=off` (module mode, sm-user-plugin v0.1.12 downloaded from git.golem15.com): vet, `-short` and the full suite are green.
- `go list -deps` has no `gopkg.in/yaml.v3`.
- **sm-sociallogin-plugin (real checkout):**
- **After `git am`:** vet, `-short` and the full suite are green, including `TestSocialGateSmoke` (5 subtests) and `TestTracerSmoke`.
- **After the require bump:** vet and `-short` green in workspace mode. Vet and the full suite green with `GOWORK=off`.
- **sm-user-plugin** (before the push): vet from fonoteka.go and in the plugin, then `-short`: ok for all four packages.
- **vue-demo-app (Task 3):**
- `pnpm test`: 202 tests pass, including `twoFactor.test.ts` (14).
- `pnpm build`: ok.
- Server render of `/account/security` and `/login`: 200.
- **sm-demo-app (Task 6 part 1):**
- **After commits A and B:** `go build ./...`, `go vet .` and `go test -short .` were green.
- **Before commit C:**
- Build and `go vet ./...` green.
- `-short ./...` green, untagged and with `-tags summercms_mcp`.
- Release-mode build green: `GOWORK=off go build -trimpath -tags summercms_mcp`, from the go.mod tags.
- `scripts/check-pins.sh`: user, sociallogin and mfa match their tags. It reports only the two leftover journal and oauthserver moves.
- **The plan's verify at HEAD `bb24207`:** `go build ./...`, `go vet .` and the three tests give exactly 3 `--- PASS: Test` lines (`TestBootAllPlugins`, `TestGoModRequiresTags`, `TestMFAMounted`).
- **Full suite (Docker) at `bb24207`:** `go test -count=1 ./...` is ok for every package, untagged and with `-tags summercms_mcp` (the MCP end-to-end tests).
- **sm-bm-app:**
- Build, vet and `-short` green after each of the 3 commits. The full suite is green too.
- The plan's verify passes: `--- PASS: TestMFAMounted`.
- **fonoteka.go:**
- The plan's verify passes: root `vet ./...`, `test -short ./...` and `build ./...`.
- Root `./...` covers only the root module, so I also ran vet and `-short` in every workspace module (`plugins/golem15/{feedback,fonoteka,golem,user}`). All green.
- Without `-short`:
- `parity` passes after the fix; it was red at v0.1.10 as well.
- Five Docker-backed tests in `plugins/golem15/fonoteka` fail at v0.1.12. They fail identically at the committed v0.1.10 pointer (baseline run), so they predate this plan. They are listed in deferred-items.md.
- Acceptance checks:
- `grep -c '"/devices"'` = 0 and `grep -c '"2fa"'` = 1.
- `summer.yaml` has no golem15.mfa.
- `go list -m all` has no go-webauthn.
- The pointer moved in the bump commit.
- **NOT RUN:**
- `scripts/build.sh release` in sm-demo-app: it refuses the leftover journal and oauthserver moves (`require_clean`). Its Go step was run by hand as above. No Nuxt release build was made for the demo in this continuation.
- A vue-demo-app type check: no tooling for it.
- A browser run of the demo screens, and any deploy.
## Exported identifiers (for plan 04)
**sm-mfa-plugin `classes`:**
- **Relying party and handles:**
- `WebAuthnFor(ctx, app) (*webauthn.WebAuthn, error)`
- `UserHandle(id uint) []byte`, `UserIDFromHandle`
- **User adapter:** `WebAuthnUser` and `NewWebAuthnUser(ctx, db, user)`.
- **Registration:**
- `BeginRegistration(ctx, app, cache, db, user, passwordless) (map[string]any, error)`
- `FinishRegistration(ctx, app, cache, tx, user, challenge, attestationJSON, name) (*models.WebAuthnCredential, error)`
- `VerifyRegistration(w, wu, session, attestationJSON)`
- **Second-factor assertion:**
- `BeginAssertion(ctx, app, db, challenge, user)`
- `FinishAssertion(ctx, app, db, challenge, user, assertionJSON) (*Assertion, error)`
- `VerifyAssertion(w, wu, session, assertionJSON)`
- `RecordAssertion(ctx, tx, *Assertion) (bool, error)`, `Assertion`
- **Passkeys:**
- `BeginPasswordless(ctx, app, cache)`
- `FinishPasswordless(ctx, app, cache, db, challenge, assertionJSON, ip) (*usermodels.User, *Assertion, error)`
- **Credential helpers:** `WebAuthnCredentialList`, `HasWebAuthnCredential`, `DeleteWebAuthnCredential`.
- **Login:** `CompleteLoginAs(ctx, app, db, r, user, source)`.
- **Constants:** `WebAuthnTimeout` (60 s), `WebAuthnStateTTL` (120 s).
- **Errors:**
- `WebAuthnFailure{Reason}` with `ErrWebAuthnChallenge`, `ErrWebAuthnInvalid`, `ErrWebAuthnVerification`, `ErrWebAuthnDuplicate`, `ErrWebAuthnNoOptions`, `ErrWebAuthnNoCredentials`
- `ErrWebAuthnUnavailable`
- **Events:**
- `WebAuthnRegisteredEvent{User, Credential, Tx}`, `WebAuthnRegisteredEventName` = `golem15.user.2fa.webauthn.registered`
- `WebAuthnRemovedEvent{User, CredentialID, Tx}`, `WebAuthnRemovedEventName` = `golem15.user.2fa.webauthn.removed`
- **Config fields:** `Config.WebAuthnRPID`, `WebAuthnRPName`, `WebAuthnOrigins`.
**Other sm-mfa-plugin packages:**
- **`controllers`:** `WebAuthnRegisterOptions`, `WebAuthnRegister`, `WebAuthnRemove`, `WebAuthnOptions`, `WebAuthnVerify`, `PasswordlessOptions`, `PasswordlessVerify`.
- **`models`:** `WebAuthnCredential` (`DisplayName()`), `DefaultCredentialName`.
- **Migration:** `202610220006_create_golem15_user_webauthn_credentials`.
- **Config keys:** `golem15.mfa.webauthn.rp_id`, `golem15.mfa.webauthn.rp_name`, `golem15.mfa.webauthn.origins` (env `SUMMER_GOLEM15__MFA__WEBAUTHN__RP_ID`, `…__RP_NAME`, `…__ORIGINS`).
**sm-mfa-plugin routes:**
- **Challenge group, `throttle:2fa-verify`:**
- `POST /_user/api/v1/2fa/webauthn/options`, `…/webauthn/verify`
- `…/webauthn/passwordless/options`, `…/webauthn/passwordless/verify`
- **Management, `jwt.auth`:**
- `POST …/webauthn/register/options`, `POST …/webauthn/register`
- `DELETE …/webauthn/{id}` (digits)
**sm-sociallogin-plugin v0.1.4:**
- `classes.Outcome.Challenge` and `classes.CompletionPayload.Challenge` (JSON `challenge,omitempty`).
- Lang key `golem15.sociallogin::lang.oauth.two_factor_unavailable`.
- Test `TestSocialGateSmoke`.
- go.mod requires sm-user-plugin v0.1.12 and SummerCMS v0.5.0.
**sm-bm-app:** `TestMFAMounted` (`mfa_test.go`).
**sm-demo-app:**
- `TestMFAMounted` (`mfa_test.go`).
- `config/golem15.mfa.yaml`.
**vue-demo-app:**
- **`app/lib/twoFactor.ts`:**
- Functions `isTwoFactorChallenge`, `normalizeRecoveryCode`, `isTotpCode`, `challengeExpired`, `svgDataUrl`.
- Types `TwoFactorChallenge`, `TwoFactorStatus`, `TotpSetup`, `WebAuthnCredentialInfo`.
- Constant `CHALLENGE_TOKEN_LENGTH`.
- **Composables:**
- `useDemoAuth()` gains `verifyChallenge` and `recoverChallenge`, and `login` returns `{challenge?}`.
- `useTwoFactor()` (`status`, `setupTotp`, `confirmTotp`, `disableTotp`).
- **UI:**
- Page `/account/security`.
- Header link to it.
- i18n `auth.twoFactor.*`, `security.*`, `header.security`.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
**1. [Environment, resolved] sm-sociallogin-plugin write access**
- **Problem:** the checkout was owned by `jin` (first run). The scratch-clone patch is described in the first draft.
- **Fix:** at the user's instruction ("you can sudo chown golem:golem sm-sociallogin-plugin"), the orchestrator ran `sudo chown -R golem:golem`. I used no sudo.
- **Result:** `git am` gave `a8e3e78`, whose tree `c5f28f3` is identical to the scratch clone's `a0161d7`. Vet, `-short` and the full suite were green there.
**2. [Drift, corrected] Release numbers**
- The plan proposed sm-user-plugin v0.1.6 and sm-sociallogin-plugin v0.1.3. Both were stale.
- Published as v0.1.12 and v0.1.4, the next free patches, as the reply confirmed.
- sm-mfa-plugin is v0.1.0 as planned.
- sm-mfa-plugin requires framework v0.5.0, not the plan's v0.3.1. Plan 02 already needed v0.5.0.
- sociallogin's require rose from v0.3.1 to v0.5.0 through `go mod tidy`. Its README Dependencies now names v0.5.0 and user v0.1.12, in the same commit.
**3. [Drift] vue-demo-app pushed with another phase's commits**
- The push included Phase 19.7's `0125e29`, `b973a76`, `3c53182` and `9c247ab`.
- The Task 4 reply authorised them by name.
**4. [Checkpoint, resolved] sm-demo-app part (1) waited on a second decision**
- The demo's user pointer had an uncommitted move to v0.1.11, left over from Phase 19.7.
- The user replied "proceed" (see "Task 6 sm-demo-app reply").
- The user checkout was fast-forwarded to v0.1.12. The journal and oauthserver moves were left unstaged and are a pending todo.
**5. [Rule 3, scope] fonoteka.go guard tests cover every user version the bump crosses**
- **Problem:** the committed pointer was v0.1.10, but the guards had not followed it. `TestHiddenNeverMarshals` expected 8 models with 10 present. The parity migration count expected 11 with 14 present, and the account-link tables had no allowance. So the plan's "+1 for DeviceAuthSession" would have left them red.
- **What `95fd58a` changes:**
- `expectedUserModels` 8 → 11, with a comment naming BackendLink, BackendLinkRequest and DeviceAuthSession.
- New `wantUserMigrations = 15` in parity.
- Allowances for `golem15_user_backend_links`, `golem15_user_backend_link_requests` and `golem15_user_device_auth_sessions`.
- `TestRequirePasswordChangeExemptSet` allows jwt.auth on `POST /_user/api/v1/backend-link/confirm`, which comes from v0.1.11.
- The deferred-routes guard drops the devices fragment and keeps 2fa. Its comment avoids the quoted `"/devices"` so the acceptance grep stays at 0.
- **Untouched:** five other Docker-backed fonoteka failures fail identically at v0.1.10 and do not inventory the user plugin, so they were left alone (deferred-items.md).
**6. [Scope] sm-bm-app `scripts/build.sh`**
- It is not in the plan's file list.
- The 117ef8f precedent adds each mounted plugin to `PLUGINS`, which drives the release go.work and the dirty check. mfa was added there.
**7. [Scope] fonoteka.go root go.mod still requires sm-user-plugin v0.1.10**
- The plan limits staging to the pointer and the tests.
- The replace makes the build use v0.1.12.
- A follow-up is in deferred-items.md.
**8. [Cosmetic] sm-mfa-plugin v0.1.0 tag message**
- It says the mode is "optional, required or disabled"; the actual values are `disabled`, `optional` and `enforced`.
- The tag is pushed and is not rewritten, since that would need a force-push (deferred-items.md).
**9-15 (from the first run):**
- Rule 2: availability check on `webauthn/register`.
- Rule 2: guarded assertion write-back.
- Discretion: `FinishAssertion` / `RecordAssertion` split, exported `VerifyRegistration` / `VerifyAssertion`, and `CompleteLoginAs`.
- Rule 2: passkey routes 403 while `two_factor_mode` is disabled.
- Rule 2: passkey ban check across addresses (ThrottleStates) and refusal of unactivated accounts.
- Extra test coverage in `TestWebAuthnSmoke`, `TestPasswordlessSmoke` and `TestSocialGateSmoke`.
- Demo UI extras: i18n keys and a cancel link. The commits and details are unchanged from the first draft (`2662252`, `6c8916f`, `44a5229`).
**16. [Rule 3, environment] The private sm-mfa-plugin repository and the demo's module-mode resolution**
- **Problem:** `go get`/`go mod tidy` with `GOWORK=off` could not fetch `git.golem15.com/golem15/sm-mfa-plugin@v0.1.0`: "could not read Username for 'https://git.golem15.com'". The repository is private; the other plugin repositories allow anonymous HTTPS. Commit C needs that fetch for the go.mod require and the go.sum hashes.
- **Fix:** for those commands only, the environment carried a git URL rewrite to SSH:
- `GIT_CONFIG_COUNT=1`
- `url.git@git.golem15.com:.insteadOf https://git.golem15.com/`
- `GOPRIVATE=git.golem15.com/*`
No global or repository git config changed.
- **Consequence (open, deferred-items.md):** `scripts/build.sh release` and `scripts/check-pins.sh` fetch in module mode. On a machine without v0.1.0 in its module cache they fail until sm-mfa-plugin is public or the build host has credentials. The user decides. sm-bm-app is unaffected (it uses a replace).
**17. [Precedent] Commit A also raises summercms v0.4.4 → v0.5.0**
- user v0.1.12 requires the framework at v0.5.0, so `go get` raised it.
- The subject names both, as `985af53` ("chore: bump user to v0.1.10 and require summercms v0.4.4") did; the plan's subject named only the user bump.
**18. [Drift] Demo sociallogin moved from v0.1.2 to v0.1.4**
- The demo was on v0.1.2, not v0.1.3, so commit B also brings v0.1.3. Its message names v0.1.3's changes.
**19. [Process] Commit B's message amended before the push**
- The first message understated v0.1.3 and named "the callback" instead of oauth-complete and oauth-register-complete.
- I amended my own unpushed commit (`9651dea` → `1c43e29`). Its tree is unchanged.
**20. [Discretion] `config/golem15.mfa.yaml` does not offer webauthn**
- The plan set methods to `[totp, email]`, which already leaves webauthn out. The comment says why: the demo has no security-key screens.
- Passwordless sign-in stays at the plugin default, off.
## Known Stubs
None.
- `status.webauthn_credentials` lists the enabled keys.
- `Admin.Reset` deletes WebAuthn credentials.
- sm-bm-app mounts the two-factor API without frontend screens, which is intended and documented in its README row. Users cannot enable a second factor there, so sign-in is unchanged.
## Threat Flags
None beyond the plan's register.
- T-19.6-26..31 and T-19.6-33 are mitigated in code and tests.
- T-19.6-32: each push followed a fetch, a range check against the authorised commits and, for sm-demo-app, a check that every introduced gitlink is on its origin. Nothing was force-pushed or deployed, and fonoteka.go was not pushed.
## Follow-ups
- **Pending todo** `.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`: sm-demo-app's uncommitted journal (`4ab94da`→`b129902`) and oauthserver (`8bbec72`→`b6a9586`) moves, left over from Phase 19.7 plan 05.
- Both commits are on origin, but no tag sits on either one.
- v0.2.4 and v0.1.3 contain them, so the bump should go to those tags.
- **deferred-items.md** holds the private-repository build issue, plus the earlier items:
- sociallogin gofmt
- vue-demo-app type check
- five fonoteka Docker-backed failures
- fonoteka root go.mod require
- the mfa tag message
- **Plan 04 patch tags:** not authorised. Plan 04 must ask before any tag.
## Next
- Plan 04, the phase's unit tests.
g15office: this executor has no g15office tool. The orchestrator posts the plan 03 update on `avoroeb4`.
## Self-Check: PASSED
- **Commits exist:**
- sm-mfa-plugin: `2662252`, `6c8916f`, `33ad164`
- sm-sociallogin-plugin: `a8e3e78`, `b752177`
- vue-demo-app: `44a5229`
- sm-demo-app: `0eb1f60`, `1c43e29`, `2e5b7da`, `bb24207`
- sm-bm-app: `aad6004`, `376cff4`, `ebb2266`
- fonoteka.go: `95fd58a`
- summercms.go: `5a64611`, `4002bea`
- **Files exist:**
- `../sm-demo-app/mfa_test.go`
- `../sm-demo-app/config/golem15.mfa.yaml`
- `../sm-demo-app/plugins/golem15/mfa/go.mod`
- the pending todo
- **On origin:**
- Tags v0.1.12, v0.1.0 and v0.1.4.
- sm-mfa-plugin master.
- sm-demo-app master at `bb24207` (`ls-remote`).
- **Working trees:**
- sm-demo-app keeps only the leftover ` M plugins/golem15/journal` and ` M plugins/golem15/oauthserver`.
- sm-mfa-plugin, sm-sociallogin-plugin and fonoteka.go are clean.
- sm-bm-app keeps only the other session's planning edits and untracked paths.

View File

@@ -0,0 +1,468 @@
From a0161d770e3f12d43d335b8c5e026e93f2993786 Mon Sep 17 00:00:00 2001
From: Jakub Zych <jakub@golem15.com>
Date: Mon, 26 Oct 2026 12:39:52 +0100
Subject: [PATCH] feat: social sign-in through the login gate
- signIn consults golem15.user's login gates (CheckLoginGates, source
social) before FinishLogin, SignedInEvent and any token (D-09)
- a 200 gate answer becomes Outcome.Challenge and
CompletionPayload.Challenge; oauth-complete and oauth-register-complete
answer exactly that body, with no token or cookie
- any other status redirects with the new two_factor_unavailable message
(en, pl); a gate error fails closed; action=link is never gated
- README "Two-factor sign-in" section, TestSocialGateSmoke with a fake
gate (no dependency on a two-factor plugin)
---
README.md | 20 +++-
classes/codes.go | 13 ++-
classes/flow.go | 32 +++++-
controllers/callback.go | 9 +-
controllers/complete.go | 9 +-
controllers/register_complete.go | 6 +
lang/en/lang.yaml | 1 +
lang/pl/lang.yaml | 1 +
social_gate_smoke_test.go | 191 +++++++++++++++++++++++++++++++
9 files changed, 266 insertions(+), 16 deletions(-)
create mode 100644 social_gate_smoke_test.go
diff --git a/README.md b/README.md
index db09474..3742c4e 100644
--- a/README.md
+++ b/README.md
@@ -88,13 +88,23 @@ app.Events.Listen[*classes.SignedInEvent]("acme", func(ctx context.Context, ev *
})
```
+### Two-factor sign-in
+
+Every social sign-in except a link consults golem15.user's login gates (`usercontrollers.CheckLoginGates`, source `userclasses.LoginSourceSocial`) before golem15.user's `FinishLogin`, before `SignedInEvent` and before any token exists. Without a gate, or when every gate allows the sign-in, nothing changes. A two-factor plugin (golem15.mfa) registers a gate that challenges social sign-ins only when its social-login setting (`two_factor_social_login`) is on; it is off by default, as in PHP.
+
+- With a challenge, `oauth-complete` (and `oauth-register-complete` when it signs an existing account in) answers 200 with exactly the gate's body, for example `{"two_factor_required":true,"challenge_token":"…","available_methods":["totp"],"expires_at":"…"}`, instead of `{token, user, action, return_to}`. No token and no token cookie are issued; the frontend completes the challenge at the two-factor plugin's routes, which answer `{token, user}`. The SPA callback page must handle `two_factor_required` before an application turns the setting on.
+- A challenged sign-in does not restore a deactivated account and does not fire `SignedInEvent`: the second factor's completion runs golem15.user's `FinishLogin` and its login event (source `two_factor`). The identity itself is stored (or refreshed) before the gate, as the provider already proved it.
+- A gate that refuses (any status other than 200, for example the second-factor failure cap) sends the browser to the callback with `?error=` and the `two_factor_unavailable` message; on `oauth-register-complete` it is a 422 with that message. A gate error fails closed: the callback redirects with the generic error, no completion code is issued, `oauth-register-complete` answers 500.
+- Linking (`action=link`) is never gated: the user is already signed in.
+- Trusted devices never skip a social challenge: a social sign-in carries no request body with a trusted-device token.
+
## Routes
| Method | Path | Middleware | Purpose |
|---|---|---|---|
| GET | `/oauth/{provider}` | `throttle:20,1` | Start: `mode` (ignored, SPA only), `action` (`login` default, `register`, `link`), `frontend_callback`, `return_to`, `link_ticket` |
| GET | `/oauth/{provider}/callback` | `throttle:20,1` | Provider return: `?code=` on success, `?error=<message>` otherwise |
-| GET | `/_user/api/v1/oauth-complete?code=` | `throttle:user-api` | 200 `{token, user, action, return_to}`; 422 empty code; 410 missing or used code |
+| GET | `/_user/api/v1/oauth-complete?code=` | `throttle:user-api` | 200 `{token, user, action, return_to}`, or the login gate's `two_factor_required` body (see Two-factor sign-in); 422 empty code; 410 missing or used code |
| POST | `/_user/api/v1/oauth-register-complete` | `throttle:user-api` | `pending_code`, `terms_accepted`, `privacy_accepted`, `marketing_consent`, optional `parental_authority` and `beta_key` (JSON or form). 200 `{token, user, action, return_to}`; 422 `{error, errors: {terms_accepted, privacy_accepted}}` for missing consent, 422 `{error}` for an unverified email match, a beta key, a closed or throttled registration, 422 `{error, errors}` for a rejected account; 410 for a missing, expired or used code |
`action=register` start requests also take `terms_accepted`, `privacy_accepted`, `marketing_consent` (true for `1`, `true`, `on`, `yes`) and `parental_authority`; any start request may carry `beta_key`.
@@ -155,9 +165,9 @@ Package `classes`:
- `Config`, `LoadConfig`; `ResolveCredentials(ctx, app, name) (ProviderConfig, bool, error)`, `CallbackRoute(name string) string`.
- `State`, `Consent`, `SealState`, `OpenState`, `ClearStateCookie`, `StateCookieName`, `StateCookieSecure`, `StateTTL`, `MaxStateCookieBytes`, `ErrInvalidState`.
- `HostSet`, `NewHostSet`, `NormalizeFrontendCallback`, `NormalizeReturnTo`, `ResolveCallback`, `WithQuery`.
-- `NewCode() (string, error)`, `CompletionPayload`, `PutCompletion`, `PullCompletion`, `CompletionTTL`; `PendingPayload` (and its `Profile()`; `TenantID` carries the tenant, JSON `tenant_id`, omitted when empty), `PutPending`, `GetPending`, `PullPending`, `PendingTTL`; `PutLinkTicket`, `PullLinkTicket`, `LinkTicketTTL`.
+- `NewCode() (string, error)`, `CompletionPayload` (with `Challenge`, JSON `challenge`, omitted when empty), `PutCompletion`, `PullCompletion`, `CompletionTTL`; `PendingPayload` (and its `Profile()`; `TenantID` carries the tenant, JSON `tenant_id`, omitted when empty), `PutPending`, `GetPending`, `PullPending`, `PendingTTL`; `PutLinkTicket`, `PullLinkTicket`, `LinkTicketTTL`.
- `SignedInEvent`, `SignedInEventName` (see Events).
-- `ActionLogin`, `ActionRegister`, `ActionLink`; `Outcome`, `OutcomeLogin`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `HandleCallback`, `CompletePending`, `ErrPendingConsumed`, `CreateUser`, `CheckBetaKey`, `PasswordBootstrapSource`, `ValidationError`, `IsValidation`, `FlowError`, `Message`, `RegisterMessage`, `ClientIP`.
+- `ActionLogin`, `ActionRegister`, `ActionLink`; `Outcome` (with `Challenge`, the login gate's body), `OutcomeLogin`, `OutcomePending`, `ReasonNoEmail`, `ReasonUnverifiedMatch`, `HandleCallback`, `CompletePending`, `ErrPendingConsumed`, `CreateUser`, `CheckBetaKey`, `PasswordBootstrapSource`, `ValidationError`, `IsValidation`, `FlowError`, `Message`, `RegisterMessage`, `ClientIP`.
Adding a provider from another plugin (which requires `golem15.sociallogin`):
@@ -182,7 +192,7 @@ func (p *Plugin) Register(app *backpack.App) error {
## Dependencies
-- `golem15.user` (`git.golem15.com/golem15/sm-user-plugin` v0.1.5 or newer): users, identities, tokens, the user payload, the provider list endpoint, and `users.email_verified_at`, the owner's proof of the address that account matching reads.
+- `golem15.user` (`git.golem15.com/golem15/sm-user-plugin`, a version with the login gate registry, `CheckLoginGates` and `LoginSourceSocial`): users, identities, tokens, the user payload, the provider list endpoint, the login gates, and `users.email_verified_at`, the owner's proof of the address that account matching reads.
- `github.com/markbates/goth` v1.82.0: the core package and the `google`, `facebook`, `github` and `azureadv2` provider packages only (never its session helper package, so `gorilla/sessions` is not linked); only `classes/providers_goth.go` imports it.
- `golang.org/x/oauth2` (token exchange errors, and the Microsoft code exchange that keeps the id_token).
- SummerCMS v0.3.1 or newer (`git.golem15.com/golem15/summercms`; `cooler.Cache.Pull` consumes the one-time codes), modules `backpack`, `bouncer`, `cabana`, `cooler`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`.
@@ -195,7 +205,7 @@ go test -short ./... # no Docker
go test ./... # also the testcontainers Postgres smoke tests
```
-The smoke tests: `TestTracerSmoke` drives a returning user's sign-in through a fake provider registered in the plugin's registry; `TestFlowSmoke` covers email matches, account creation at the callback and on the pending screen, and the token cookie; `TestLinkSmoke` covers the link ticket, bearer linking and the start-request errors; `TestSettingsOverrideSmoke` covers the admin settings override; `TestGothProvidersSmoke` drives the four goth wrappers against fake provider APIs.
+The smoke tests: `TestTracerSmoke` drives a returning user's sign-in through a fake provider registered in the plugin's registry; `TestFlowSmoke` covers email matches, account creation at the callback and on the pending screen, and the token cookie; `TestLinkSmoke` covers the link ticket, bearer linking and the start-request errors; `TestSocialGateSmoke` adds a fake login gate to golem15.user's registry and covers the unchanged sign-in without a challenge, the `two_factor_required` body from `oauth-complete` and `oauth-register-complete` with no token and the deactivated account left deactivated, the refusal redirect with `two_factor_unavailable`, a gate error failing closed and an ungated link; `TestSettingsOverrideSmoke` covers the admin settings override; `TestGothProvidersSmoke` drives the four goth wrappers against fake provider APIs.
The unit tests, one per decision. Without Docker (`-short`): `TestRegistry` (names, reserved names, order, Build), `TestNormalizeFrontendCallback` and `TestNormalizeReturnTo` (the callback and return_to tables), `TestCodes` (64-character codes, sealed cache values, TTLs, single use), `TestEmailVerified` (per-provider email-verified rules), `TestGothProviders` (authorization URLs, sign-ins, the Microsoft tenant from the id_token and its refusals: no id_token, malformed, foreign `aud`, non-GUID `tid`; expired codes without response bodies, timeouts, no request outside the provider's hosts against fake APIs) and `TestErrorMessages` (every message in en and pl, the PHP texts byte for byte). On Postgres: `TestStateCookie` (sealing, refusals, attributes, cleared on every callback outcome), `TestRoutes`, `TestRouteMiddleware` (throttles from the route table, the 21st request answers 429), `TestProvidersList`, `TestMatching`, `TestNOAuth` (a Microsoft mail matching an existing user never links, in any tenant), `TestSoftDeleted`, `TestInactiveAndBanned` (including a banned and deactivated account, refused on every path, never restored and never mailed), `TestRegistrationGates`, `TestConsent`, `TestBetaGate`, `TestIdentityTokens`, `TestLink`, `TestLinkTicket`, `TestOAuthComplete`, `TestTokenCookieMode`, `TestRegisterComplete` (including 16 concurrent submissions of one code: one 200, fifteen 410), `TestPreAccountTakeover` (a verified sign-in never links onto a password-less account), `TestTenantProfileData` (`profile_data.tenant_id` only with a tenant, kept through a pending sign-in), `TestSignedInEvent` (one event per login, register, link, pending completion and restoring login with every field; failing sign-in, register and created listeners are logged and fail nothing; a listener's write reaches the payload) and `TestSettings` (overrides, write-only secrets, no secret in any log line, the PHP environment names).
diff --git a/classes/codes.go b/classes/codes.go
index b2dbb5b..bf5f765 100644
--- a/classes/codes.go
+++ b/classes/codes.go
@@ -31,12 +31,15 @@ const (
)
// CompletionPayload is what GET /_user/api/v1/oauth-complete hands over
-// for a code.
+// for a code. Challenge is set (and Token and User are empty) when a login
+// gate held the sign-in back for a second factor; oauth-complete then
+// answers exactly that body.
type CompletionPayload struct {
- Token string `json:"token"`
- User map[string]any `json:"user"`
- Action string `json:"action"`
- ReturnTo string `json:"return_to"`
+ Token string `json:"token"`
+ User map[string]any `json:"user"`
+ Action string `json:"action"`
+ ReturnTo string `json:"return_to"`
+ Challenge map[string]any `json:"challenge,omitempty"`
}
// PendingPayload is a sign-in that ended on the frontend's pending
diff --git a/classes/flow.go b/classes/flow.go
index 452eb7e..2cd42fc 100644
--- a/classes/flow.go
+++ b/classes/flow.go
@@ -6,6 +6,7 @@ import (
"net/http"
"time"
+ userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usercontrollers "git.golem15.com/golem15/sm-user-plugin/controllers"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -50,6 +51,12 @@ type Outcome struct {
Action string
ReturnTo string
Pending *PendingPayload
+ // Challenge is set instead of Token and User when a login gate (a
+ // two-factor plugin) held the sign-in back: the body the frontend gets
+ // from oauth-complete or oauth-register-complete, for example
+ // {two_factor_required, challenge_token, available_methods,
+ // expires_at}.
+ Challenge map[string]any
}
// HandleCallback runs the account matching of the PHP SocialAuth component
@@ -321,12 +328,35 @@ func loginWithIdentity(ctx context.Context, app *backpack.App, r *http.Request,
// signIn runs the steps of a password login after authentication, fires
// ev (SignedInEvent) and returns the login outcome with a fresh session
// token for the user row read again after the listeners.
+//
+// First it consults golem15.user's login gates with source social (D-09),
+// before FinishLogin, so a deactivated account is not restored and no token
+// exists before a second factor. A gate that answers 200 (a two-factor
+// challenge) makes the outcome carry that body as Challenge instead of a
+// token, and the sign-in stops there: FinishLogin, SignedInEvent and the
+// token are left to the second factor's completion. Any other status is a
+// refusal (two_factor_unavailable); a gate error is returned, so the
+// sign-in fails closed. A link (linkIdentity) never comes here.
func signIn(ctx context.Context, app *backpack.App, r *http.Request, db *gorm.DB, user *usermodels.User, ev *SignedInEvent, returnTo string) (Outcome, error) {
+ gate, err := usercontrollers.CheckLoginGates(ctx, app, userclasses.LoginAttempt{User: user, Source: userclasses.LoginSourceSocial, Request: r})
+ if err != nil {
+ return Outcome{}, err
+ }
+ if gate != nil {
+ if gate.Status != 0 && gate.Status != http.StatusOK {
+ return Outcome{}, flowErr("two_factor_unavailable")
+ }
+ body := gate.Body
+ if body == nil {
+ body = map[string]any{}
+ }
+ return Outcome{Kind: OutcomeLogin, Challenge: body, Action: ev.Action, ReturnTo: returnTo}, nil
+ }
if err := usercontrollers.FinishLogin(ctx, app, db, user, ClientIP(app, r)); err != nil {
return Outcome{}, err
}
action := ev.Action
- user, err := fireSignedIn(ctx, app, db, ev)
+ user, err = fireSignedIn(ctx, app, db, ev)
if err != nil {
return Outcome{}, err
}
diff --git a/controllers/callback.go b/controllers/callback.go
index 956d791..3f3b8de 100644
--- a/controllers/callback.go
+++ b/controllers/callback.go
@@ -111,10 +111,11 @@ func finishCallback(w http.ResponseWriter, r *http.Request, app *backpack.App, c
return
}
code, err := classes.PutCompletion(r.Context(), app, classes.CompletionPayload{
- Token: out.Token,
- User: out.User,
- Action: out.Action,
- ReturnTo: out.ReturnTo,
+ Token: out.Token,
+ User: out.User,
+ Action: out.Action,
+ ReturnTo: out.ReturnTo,
+ Challenge: out.Challenge,
})
if err != nil {
slog.Default().Error("golem15.sociallogin: store completion code", "err", err)
diff --git a/controllers/complete.go b/controllers/complete.go
index de2a7ba..481082f 100644
--- a/controllers/complete.go
+++ b/controllers/complete.go
@@ -13,7 +13,10 @@ import (
// return_to}, 422 for an empty code and 410 for a missing, used or expired
// one, as the PHP API does. With golem15.sociallogin.token_cookie the
// token is set as golem15.user's token cookie (bouncer TokenCookie) and
-// left out of the body.
+// left out of the body. When a login gate held the sign-in back (D-09), it
+// answers 200 with exactly the gate's body, for example
+// {two_factor_required, challenge_token, available_methods, expires_at},
+// and no token or cookie.
func Complete(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
@@ -31,6 +34,10 @@ func Complete(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusGone, map[string]any{"error": "OAuth completion code is invalid or expired"})
return
}
+ if p.Challenge != nil {
+ writeJSON(w, http.StatusOK, p.Challenge)
+ return
+ }
writeSession(w, app, p.Token, p.User, p.Action, classes.ActionLogin, p.ReturnTo)
}
}
diff --git a/controllers/register_complete.go b/controllers/register_complete.go
index 647775f..48d6254 100644
--- a/controllers/register_complete.go
+++ b/controllers/register_complete.go
@@ -21,6 +21,8 @@ import (
// only inside the final transaction, and a request that loses that race
// answers 410. Responses follow the PHP API: 200 {token, user, action
// (register default), return_to}, 422 {error[, errors]}, 410 {error}.
+// When the sign-in of an existing account is held back by a login gate
+// (D-09), it answers 200 with exactly the gate's body instead.
func RegisterComplete(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fields, err := readFields(r)
@@ -107,6 +109,10 @@ func RegisterComplete(app *backpack.App) http.HandlerFunc {
}
return
}
+ if out.Challenge != nil {
+ writeJSON(w, http.StatusOK, out.Challenge)
+ return
+ }
writeSession(w, app, out.Token, out.User, out.Action, classes.ActionRegister, out.ReturnTo)
}
}
diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml
index 8159c5e..2db3d73 100644
--- a/lang/en/lang.yaml
+++ b/lang/en/lang.yaml
@@ -25,6 +25,7 @@ oauth:
provider_not_configured: ":provider login is not configured. Please contact the administrator."
invalid_action: "Invalid OAuth action specified."
must_be_logged_in: "You must be logged in to link an OAuth account."
+ two_factor_unavailable: "Two-factor verification is temporarily unavailable. Try again later."
register:
consent_required: "You must accept the terms and the privacy policy to complete the registration."
diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml
index 803e5f2..7f336e2 100644
--- a/lang/pl/lang.yaml
+++ b/lang/pl/lang.yaml
@@ -25,6 +25,7 @@ oauth:
provider_not_configured: "Logowanie przez :provider nie jest skonfigurowane. Skontaktuj się z administratorem."
invalid_action: "Wskazano nieprawidłową akcję logowania."
must_be_logged_in: "Musisz być zalogowany, aby połączyć konto dostawcy logowania."
+ two_factor_unavailable: "Weryfikacja dwuetapowa jest chwilowo niedostępna. Spróbuj ponownie później."
register:
consent_required: "Musisz zaakceptować regulamin i politykę prywatności, aby dokończyć rejestrację."
diff --git a/social_gate_smoke_test.go b/social_gate_smoke_test.go
new file mode 100644
index 0000000..4dd5e3e
--- /dev/null
+++ b/social_gate_smoke_test.go
@@ -0,0 +1,191 @@
+package sociallogin_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+
+ "git.golem15.com/golem15/sm-sociallogin-plugin/classes"
+ userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
+ usermodels "git.golem15.com/golem15/sm-user-plugin/models"
+)
+
+// fakeGate is a login gate a test adds to golem15.user's published
+// registry, standing in for a two-factor plugin. It answers result (or
+// err) for the users in targets and allows everyone else, and records the
+// attempts it saw.
+type fakeGate struct {
+ mu sync.Mutex
+ targets map[uint]bool
+ result *userclasses.LoginGateResult
+ err error
+ attempts []userclasses.LoginAttempt
+}
+
+func (g *fakeGate) CheckLogin(_ context.Context, a userclasses.LoginAttempt) (*userclasses.LoginGateResult, error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.attempts = append(g.attempts, a)
+ if a.User == nil || !g.targets[a.User.ID] {
+ return nil, nil
+ }
+ return g.result, g.err
+}
+
+// set makes the gate answer result or err for user.
+func (g *fakeGate) set(user *usermodels.User, result *userclasses.LoginGateResult, err error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.targets = map[uint]bool{user.ID: true}
+ g.result, g.err = result, err
+ g.attempts = nil
+}
+
+// calls returns the attempts seen since the last set.
+func (g *fakeGate) calls() []userclasses.LoginAttempt {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return append([]userclasses.LoginAttempt(nil), g.attempts...)
+}
+
+// TestSocialGateSmoke covers D-09: a social sign-in consults golem15.user's
+// login gates with source social before anything signs the user in.
+// Without a challenge it answers as before; a challenge replaces the token
+// in oauth-complete and oauth-register-complete with the gate's body and
+// leaves a deactivated account deactivated; a refusal redirects with the
+// two_factor_unavailable message; a gate error fails closed; linking is
+// never gated.
+func TestSocialGateSmoke(t *testing.T) {
+ e := bootSocial(t, nil)
+ idp := registerFake(t, e.registry(t), "fake", "Fake")
+ e.credentials(t, "fake")
+ gates, ok := e.app.Lookup[*userclasses.LoginGates]()
+ if !ok || gates == nil {
+ t.Fatal("golem15.user published no login gate registry")
+ }
+ gate := &fakeGate{}
+ gates.Add(gate)
+
+ challenge := map[string]any{
+ "two_factor_required": true,
+ "challenge_token": strings.Repeat("c", 64),
+ "available_methods": []any{"totp"},
+ "expires_at": "2026-10-26T12:05:00+00:00",
+ }
+ const challengeJSON = `{"available_methods":["totp"],"challenge_token":"` +
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +
+ `","expires_at":"2026-10-26T12:05:00+00:00","two_factor_required":true}`
+
+ t.Run("no_gate_unchanged", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-1")
+ gate.set(&usermodels.User{}, nil, nil)
+ idp.on("g1", classes.Profile{ID: "fake-gate-1", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g1", nil)
+ res, got := e.complete(t, done.Query().Get("code"))
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("complete: %d %v", res.StatusCode, got)
+ }
+ if tok, _ := got["token"].(string); tok == "" || got["user"] == nil || got["action"] != "login" || got["return_to"] != "/dashboard" {
+ t.Fatalf("complete body %v", got)
+ }
+ if _, ok := got["challenge"]; ok {
+ t.Fatalf("challenge key in a plain sign-in: %v", got)
+ }
+ calls := gate.calls()
+ if len(calls) != 1 || calls[0].Source != userclasses.LoginSourceSocial || calls[0].User.ID != member.ID || calls[0].Request == nil || calls[0].Fields != nil {
+ t.Fatalf("gate attempts %+v", calls)
+ }
+ })
+
+ t.Run("challenge_body", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-2")
+ if err := e.gdb.Delete(member).Error; err != nil {
+ t.Fatal(err)
+ }
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusOK, Body: challenge}, nil)
+ idp.on("g2", classes.Profile{ID: "fake-gate-2", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g2", nil)
+ code := done.Query().Get("code")
+ if code == "" || done.Query().Get("error") != "" {
+ t.Fatalf("callback redirect %s", done)
+ }
+ res, body := e.do(t, e.browser(t), http.MethodGet, "/_user/api/v1/oauth-complete?code="+url.QueryEscape(code), nil, nil)
+ if res.StatusCode != http.StatusOK || body != challengeJSON {
+ t.Fatalf("oauth-complete: %d %s", res.StatusCode, body)
+ }
+ for _, c := range res.Cookies() {
+ if c.Value != "" {
+ t.Fatalf("cookie %s set with a challenge", c.Name)
+ }
+ }
+ var back usermodels.User
+ if err := e.gdb.Unscoped().Where("id = ?", member.ID).Take(&back).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !back.DeletedAt.Valid {
+ t.Fatal("a challenged social sign-in restored the deactivated account")
+ }
+
+ // oauth-register-complete of a pending registration whose identity
+ // now belongs to the account answers the challenge too.
+ pending, err := classes.PutPending(t.Context(), e.app, classes.PendingPayload{Provider: "fake", ProviderID: "fake-gate-2", ReturnTo: "/welcome"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, body = e.do(t, e.browser(t), http.MethodPost, "/_user/api/v1/oauth-register-complete",
+ strings.NewReader(`{"pending_code":"`+pending+`","terms_accepted":true,"privacy_accepted":true}`),
+ http.Header{"Content-Type": {"application/json"}})
+ if res.StatusCode != http.StatusOK || body != challengeJSON {
+ t.Fatalf("oauth-register-complete: %d %s", res.StatusCode, body)
+ }
+ })
+
+ t.Run("refusal_redirects", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-3")
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusUnauthorized, Body: map[string]any{"error": "Invalid or expired challenge."}}, nil)
+ idp.on("g3", classes.Profile{ID: "fake-gate-3", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g3", nil)
+ if done.Query().Get("code") != "" || done.Query().Get("error") != "Two-factor verification is temporarily unavailable. Try again later." {
+ t.Fatalf("refusal redirect %s", done)
+ }
+ })
+
+ t.Run("gate_error_fails_closed", func(t *testing.T) {
+ member := e.user(t, "")
+ e.identity(t, member, "fake", "fake-gate-4")
+ gate.set(member, nil, errors.New("gate store down"))
+ idp.on("g4", classes.Profile{ID: "fake-gate-4", Email: member.Email})
+ done := e.signIn(t, e.browser(t), "fake", spaQuery("login", "/dashboard"), "g4", nil)
+ if done.Query().Get("code") != "" || done.Query().Get("error") == "" || strings.Contains(done.RawQuery, "store") {
+ t.Fatalf("gate error redirect %s", done)
+ }
+ if len(gate.calls()) != 1 {
+ t.Fatalf("gate attempts %d", len(gate.calls()))
+ }
+ })
+
+ t.Run("link_not_gated", func(t *testing.T) {
+ member := e.user(t, "")
+ gate.set(member, &userclasses.LoginGateResult{Status: http.StatusOK, Body: challenge}, nil)
+ ticket := e.ticketFor(t, http.Header{"Authorization": {"Bearer " + e.sessionToken(t, member)}})
+ idp.on("g5", classes.Profile{ID: "fake-gate-5", Email: "other-" + member.Email})
+ done := e.signIn(t, e.browser(t), "fake", linkQuery(ticket), "g5", nil)
+ res, got := e.complete(t, done.Query().Get("code"))
+ if res.StatusCode != http.StatusOK || got["action"] != "link" {
+ t.Fatalf("link: %d %v (redirect %s)", res.StatusCode, got, done)
+ }
+ if tok, _ := got["token"].(string); tok == "" {
+ t.Fatalf("link without token: %v", got)
+ }
+ if n := len(gate.calls()); n != 0 {
+ t.Fatalf("link consulted the gate %d times", n)
+ }
+ })
+}
--
2.56.0

View File

@@ -0,0 +1,319 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
plan: 04
subsystem: testing
tags: [sm-user-plugin, sm-mfa-plugin, sm-sociallogin-plugin, unit-tests, security-review, phase-gate, testcontainers, webauthn, totp, device-flow, d-14]
requires:
- phase: 19.6-01
provides: login gates, password-change hook, Reset 2FA slot, device authorization (sm-user-plugin v0.1.12)
- phase: 19.6-02
provides: sm-mfa-plugin core (TOTP, recovery and email codes, failure cap, trusted devices, settings, admin reset)
- phase: 19.6-03
provides: WebAuthn and passkeys (sm-mfa-plugin v0.1.0), the social-login gate (sm-sociallogin-plugin v0.1.4), adoption in sm-demo-app, sm-bm-app, fonoteka.go
provides:
- scripts/check-phase19.6.sh, a fail-closed gate over seven repositories (self-test, go, userplugin, mfa, sociallogin, demo, demo-web, bm, fonoteka, docs, docker)
- sm-user-plugin unit tests for the gate chain, Login order, password-change sources, the reset action, the whole device flow and its migration
- sm-mfa-plugin unit tests, one per decision, the PHP bodies, the login-path invariant and the PHP security test map
- sm-sociallogin-plugin TestSocialGate
- 19.6-SECURITY-REVIEW.md with the explicit D-14 section; 19.6-VALIDATION.md validated
- one post-release fix in sm-mfa-plugin (71d8f27), released as sm-mfa-plugin v0.1.1 and pinned by sm-demo-app (pushed) and sm-bm-app (committed; push waits on another session's commits)
- finding F-1 (activate-by-code) deferred with a todo
affects: [19.6 verification, sm-demo-app, sm-bm-app, fonoteka.go, sm-mfa-plugin release]
actuals:
tokens: 97673 # MEASURED chars/4 over added lines: summercms.go e95277a..692beb5 (80239), sm-user-plugin 3b22134..1f3266a (94434), sm-mfa-plugin 33ad164..d08f1ec (204699), sm-sociallogin-plugin b752177..14d5f93 (11321)
tasks: 6
commits: 7 # MEASURED in summercms.go before this final SUMMARY commit: git rev-list --count e95277a..HEAD (5 Task 1-4 commits, the draft summary, the Task 6 docs commit)
sm_demo_app_commits: 1 # 9166a1e
sm_bm_app_commits: 1 # fe4e292
sm_user_plugin_commits: 3 # MEASURED: git rev-list --count 3b22134..1f3266a
sm_mfa_plugin_commits: 5 # MEASURED: git rev-list --count 33ad164..d08f1ec
sm_sociallogin_plugin_commits: 1 # MEASURED: git rev-list --count b752177..14d5f93
plan_head_before: e95277a21049bf92a1f427d2d9379c4d26da35d8
plan_head_after: see the final SUMMARY commit
sm_user_plugin_head_before: 3b22134
sm_user_plugin_head_after: 1f3266a
sm_mfa_plugin_head_before: 33ad164
sm_mfa_plugin_head_after: d08f1ec
sm_sociallogin_plugin_head_before: b752177
sm_sociallogin_plugin_head_after: 14d5f93
sm_demo_app_head_before: bb24207
sm_demo_app_head_after: 9166a1e
sm_bm_app_head_before: 4759349
sm_bm_app_commit: fe4e292
tech-stack:
added: []
patterns:
- "Phase gate per phase: go test -json detector refusing fail, skip, zero tests and missing names; --self-test on planted output"
- "Token-mint inventory test: every mint call site read from the source and listed with its reason"
- "Dry-run GORM dialector in tests to run DB-touching ceremony code without Postgres"
- "Mutation checks on guarded UPDATEs recorded in the security review"
key-files:
created:
- scripts/check-phase19.6.sh
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY-REVIEW.md
- .planning/todos/pending/2026-10-26-activate-by-code-second-factor.md
- ../fonoteka.go/plugins/golem15/user/login_gate_test.go
- ../fonoteka.go/plugins/golem15/user/password_changed_test.go
- ../fonoteka.go/plugins/golem15/user/reset_two_factor_test.go
- ../fonoteka.go/plugins/golem15/user/device_auth_test.go
- ../fonoteka.go/plugins/golem15/user/classes/device_auth_test.go
- ../fonoteka.go/plugins/golem15/user/updates/device_auth_sessions_test.go
- ../sm-mfa-plugin/{gate,challenge,totp,recovery,email,trusted,settings,status,admin,tokens_at_rest,migrations,webauthn,passwordless,routes,php_bodies,login_paths,php_method_map,php_port}_test.go
- ../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md
- ../sm-mfa-plugin/testdata/php_methods.txt
- ../sm-sociallogin-plugin/social_gate_test.go
modified:
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/deferred-items.md
- ../fonoteka.go/plugins/golem15/user/README.md
- ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go
- ../sm-mfa-plugin/{classes/config.go,models/settings.go,lang/en/lang.yaml,lang/pl/lang.yaml,README.md,harness_test.go}
- ../sm-sociallogin-plugin/README.md
- ../sm-demo-app/{plugins/golem15/mfa,go.mod,go.sum}
- ../sm-bm-app/plugins/golem15/mfa
key-decisions:
- "An empty settings list (methods or enforce groups) is an empty value and falls back to the configuration, as the README promised; the form stores it as NULL (sm-mfa-plugin 71d8f27, post-release fix)"
- "Error bodies are pinned byte for byte; multi-key success bodies by key set and fixed values, because Go maps sort keys where PHP kept insertion order (recorded, not changed)"
- "Finding F-1 (activate-by-code mints without the second factor when require_activation is off) was raised at Task 5 and deferred by the user with a todo; the accepted-debt reason is corrected in the security review"
- "Task 5 reply: release sm-mfa-plugin v0.1.1 with demo and bm pointer bumps, defer F-1, push the test-only commits; no tags for the user or social plugins"
patterns-established:
- "check-phase<N>.sh stage per repository, names appended per plan, docker NOT RUN explicit"
requirements-completed: [SC-4, D-02, D-05, D-06, D-07, D-08, D-09, D-10, D-11, D-12, D-12a, D-12b, D-12c, D-13, D-14, D-15, D-16, D-17, D-18, D-19]
duration: about 76 min to the Task 5 checkpoint (12:50Z to 14:06Z), then about 20 min for Task 6 (14:15Z to 14:35Z)
completed: 2026-10-26
status: complete
---
# Phase 19.6 Plan 04: Unit tests and security review Summary
**Status: complete.** All 6 tasks are done. The whole gate passes across seven repositories with Docker. The one post-release fix (sm-mfa-plugin `71d8f27`) shipped as **sm-mfa-plugin v0.1.1**. sm-demo-app is pinned to it and pushed. sm-bm-app is pinned in a local commit; its push waits because its unpushed range also holds another session's phase 03.2 commits. Finding F-1 is deferred with a todo.
**One fail-closed gate over seven repositories, 41 new named unit tests across sm-user-plugin, sm-mfa-plugin and sm-sociallogin-plugin (one per decision, the PHP bodies, the token-mint inventory and every login path), the PHP security test mapped, a security review with the D-14 passkey section, one real defect fixed (an untouched settings form disabled every second factor) and one finding raised (activate-by-code).**
## Performance
- **Started:** 2026-10-26T12:50Z
- **Checkpoint (Task 5):** 2026-10-26T14:06Z
- **Task 6:** 2026-10-26T14:15Z to 14:35Z
- **Tasks:** 6 of 6
## Commits per repository (in order)
**summercms.go** (base `e95277a`):
| Task | Commit | Message |
|------|--------|---------|
| 1 | `947009a` | test(19.6): fail-closed phase gate |
| 2 | `e9785f7` | test(19.6): gate requires the user-plugin unit tests |
| 3 | `531e7ab` | test(19.6): gate requires the mfa and social gate tests |
| 4 | `ea4680e` | test(19.6): gate requires the PHP map |
| 4 | `692beb5` | docs(19.6): security review and validation map |
**sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`, base `3b22134` = v0.1.12; branch master, 3 commits ahead of origin):
| Task | Commit | Message |
|------|--------|---------|
| 2 | `6844d36` | test: login gates, password-changed sources and the reset action |
| 2 | `2c68979` | test: start the updates package's Postgres only without -short |
| 2 | `1f3266a` | test: device authorization flow and its migration |
**sm-mfa-plugin** (base `33ad164` = v0.1.0):
| Task | Commit | Message |
|------|--------|---------|
| 3 | `71d8f27` | fix: an empty settings list falls back to the configuration |
| 3 | `8ec140e` | test: TOTP, QR, recovery and email codes, the gate, challenges and the failure cap |
| 3 | `2aff160` | test: trusted devices, revocation, settings, status, admin reset, tokens at rest and migrations |
| 3 | `ab886f8` | test: WebAuthn, passkeys, routes, PHP bodies and the login-path invariant |
| 4 | `d08f1ec` | test: PHP security test map |
**sm-sociallogin-plugin** (base `b752177` = v0.1.4):
| Task | Commit | Message |
|------|--------|---------|
| 3 | `14d5f93` | test: social sign-in gate |
**Task 6 (applications):**
| Repository | Commit | Message |
|------------|--------|---------|
| sm-demo-app | `9166a1e` | chore: bump sm-mfa-plugin to v0.1.1 (gitlink `plugins/golem15/mfa` to `d08f1ec`, go.mod require v0.1.1, go.sum tidied: only the mfa lines changed) |
| sm-bm-app | `fe4e292` | chore: bump sm-mfa-plugin to v0.1.1 (gitlink only; bm uses a replace) |
fonoteka.go has no commit from this plan. The user plugin has no release, so its pointer needs no bump. fonoteka.go's working tree shows ` M plugins/golem15/user`, because the submodule sits on the three pushed test commits while the committed pointer is still `3b22134`. fonoteka.go was not pushed.
## Remote operations (in order)
Before every push I ran `git fetch` and checked that `origin/master..master` held only this plan's commits and that nothing was behind. Nothing was force-pushed or deployed.
| Repository | Ref | Before → after |
|------------|-----|----------------|
| sm-user-plugin | `refs/heads/master` | `3b22134` → `1f3266a417777bc0fcf3b7ea3578d0d053150e9f` (3 test commits; no tag) |
| sm-mfa-plugin | `refs/heads/master` | `33ad164` → `d08f1ec430686ac99da9c30ca85a56fc6e80995e` (5 commits) |
| sm-mfa-plugin | `refs/tags/v0.1.1` (annotated) | new: tag object `c461c61cab41bda3ec097413fd79e5a2371fcfb1` → commit `d08f1ec430686ac99da9c30ca85a56fc6e80995e`; the message names the modes disabled, optional and enforced |
| sm-sociallogin-plugin | `refs/heads/master` | `b752177` → `14d5f93302082d5b94b574d6c7c24e5e60dbd902` (1 test commit; no tag) |
| sm-demo-app | `refs/heads/master` | `bb24207` → `9166a1ec5be5b689b766271214523d00a07b125d` (exactly the bump commit; the leftover journal and oauthserver moves stay unstaged) |
| sm-bm-app | not pushed | `origin/master` is still `ebb2266`. `origin/master..master` holds 7 commits: 6 phase 03.2 planning commits of another session (`17e8bf8`, `a5e1d9a`, `a16ba1d`, `009ff98`, `4759349`, then `8733060` made on top of my bump while I worked) plus my `fe4e292`. Pushing master would publish that session's commits, so I left it for the user or that session. |
`ls-remote` confirms the four branch heads and the tag above.
## merge-base results (fix `71d8f27` against every pin, in sm-mfa-plugin)
| Pin | Commit | `merge-base --is-ancestor 71d8f27 <pin>` |
|-----|--------|----------------------------------------|
| tag v0.1.1 | `d08f1ec` | 0 |
| sm-demo-app `plugins/golem15/mfa` gitlink at origin/master `9166a1e` | `d08f1ec` | 0 |
| sm-demo-app go.mod require v0.1.1 | `d08f1ec` | 0 |
| sm-bm-app `plugins/golem15/mfa` gitlink at master (`fe4e292`, also in HEAD `8733060`) | `d08f1ec` | 0 |
Task 6 verify at the final pins:
- `--demo`, `--bm` and `--fonoteka` pass.
- sm-demo-app also passes a module-mode release build (`GOWORK=off go build -trimpath -tags summercms_mcp`, which fetches sm-mfa-plugin v0.1.1 anonymously, now that the repository is public) and `go test -short ./...`.
- sm-bm-app passes `go test -short ./...`.
## Tests added (names the gate requires)
- **sm-user-plugin, Docker-free:** `TestLoginGates` (also under `-race`), `TestDeviceAuthURL`, `TestDeviceDisplayName`, `TestDeviceCodes` (classes).
- **sm-user-plugin, Postgres:** `TestLoginGateOrder`, `TestPasswordChangedSources`, `TestResetTwoFactorAction`, `TestDeviceInitiate`, `TestDeviceStatus`, `TestDeviceAuthorize`, `TestDeviceHandOverConcurrent`, `TestDeviceListAndRevoke`, `TestDeviceRevokeAfterRefresh`, `TestDeviceTokensAtRest`, `TestDeviceAuthSessionsMigration` (updates).
- **sm-mfa-plugin, Docker-free:** `TestTOTP`, `TestQRSVG`, `TestRecoveryCodeFormat`, `TestMaskEmail`, `TestEmailExpiryMinutes`, `TestWebAuthnShapes`, `TestRoutesAndBuckets`, `TestPHPMethodMap`, `TestPHPUser005`.
- **sm-mfa-plugin, Postgres:** `TestGate`, `TestChallengeLifecycle`, `TestFailureCap`, `TestRecovery`, `TestEmailCode`, `TestTrustedDevice`, `TestRevocation`, `TestSettings`, `TestStatusShape`, `TestAdminReset`, `TestTokensAtRest`, `TestMigrations`, `TestWebAuthn`, `TestPasswordless`, `TestPHPErrorBodies`, `TestEveryLoginPathIsGated`.
- **sm-sociallogin-plugin, Postgres:** `TestSocialGate`.
- The smoke tests of plans 01-03 stay and are required by name too.
## Gate stages (`scripts/check-phase19.6.sh --all`, clean run 13:59Z, 4 m 55 s)
| Stage | State | What ran |
|-------|-------|----------|
| self-test | PASS | detectors on planted output, additive, pin, mount and migration checks, docker NOT RUN rule on a faked PATH |
| go | PASS | vet and `-short` of the framework; its go.mod requires none of the three libraries |
| userplugin | PASS | vet and `-short` through the fonoteka.go workspace, 5 + 1 (`-race`) + 1 (classes) named tests, additive check against `50da92c` (56 declarations added, none removed or changed), migration check (only `202610260001_create_device_auth_sessions` added) |
| mfa | PASS | vet and `-short`, 11 named tests, no `gopkg.in/yaml.v3`, the three pins in go.mod |
| sociallogin | PASS | vet and `-short`, 9 named tests, Phase 19.5 dependency rules |
| demo | PASS | build, vet, `TestBootAllPlugins`, `TestGoModRequiresTags`, `TestMFAMounted` |
| demo-web | PASS | `pnpm test`: 202 tests, `app/lib/twoFactor.test.ts` required |
| bm | PASS | build, vet, `TestSocialLoginMounted`, `TestMFAMounted` |
| fonoteka | PASS | vet and `-short` of the root and the four workspace modules; no golem15.mfa mount; no go-webauthn, pquerna or boombuler module |
| docs | PASS | decision note pins, STACK rows, `TestDocsTree`, `docs:build --check`, no consuming application named in modules/, docs/ or the two plugin READMEs |
| docker | PASS | 15 + 1 user-plugin, 21 mfa and 2 sociallogin Postgres tests by name |
**Docker:** reachable as golem; no stage was NOT RUN. Not part of the gate (scoped out, predating this phase, deferred-items.md): the five Docker-backed failures in fonoteka.go's `plugins/golem15/fonoteka` (the fonoteka stage runs `-short`), and sm-demo-app's `scripts/build.sh release`, which refuses the leftover journal and oauthserver submodule moves. **NOT RUN:** a real-browser WebAuthn ceremony and the demo screens in a browser (manual-only, 19.6-VALIDATION.md).
Mutation checks (19.6-SECURITY-REVIEW.md "Method"): seven mitigations removed one at a time, each caught by its test, each reverted. The first `--all` run overlapped those edits, so the result above is a second, clean run.
## Defects found and fixed
| Commit | Repo | Found by | Defect | Fix |
|--------|------|----------|--------|-----|
| `71d8f27` | sm-mfa-plugin | `TestSettings/form_round_trip_keeps_the_configuration` (written for this) | The admin form shows a NULL method list as an empty taglist and sends `[]` back on every save, so saving the settings once stored `[]` and made every method unavailable (TOTP setup answered 403). | Empty lists are stored as NULL (`Settings.BeforeValidate`) and an empty stored list falls back to the configuration (`overlay`), as the README promised; README and en/pl comments updated. |
| `2c68979` | sm-user-plugin (test harness) | the gate's userplugin stage | The `updates` package's TestMain scanned `os.Args` for `-test.short`, so every `-short` run started Postgres (and would fail without Docker). | `flag.Parse` and `testing.Short()`, as CLAUDE.md prescribes. Test-only. |
## Post-release fixes
Production commits this plan made after plan 03's release (outside `*_test.go`, `testdata/` and README-only changes), against every pin that lacks them:
| Repository | Commit | Files | Released pins that lack it |
|------------|--------|-------|----------------------------|
| sm-mfa-plugin | `71d8f27` | `classes/config.go`, `models/settings.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md` | tag `v0.1.0` (`33ad164`); sm-demo-app gitlink `plugins/golem15/mfa` (`33ad164`) and its go.mod require `sm-mfa-plugin v0.1.0`; sm-bm-app gitlink `plugins/golem15/mfa` (`33ad164`) |
At the checkpoint, `merge-base --is-ancestor 71d8f27 33ad164` exited 1 (no pin contained the fix) and `tag --points-at HEAD` was empty. sm-user-plugin and sm-sociallogin-plugin had test and README commits only, so there was nothing to release there. After Task 6 every pin contains the fix (table above).
## Task 5 reply
Plan 03's Task 4 reply did not pre-authorise patch releases for this plan, so the checkpoint went to the user. The user's decision, relayed verbatim by the orchestrator (from AskUserQuestion):
> 1. RELEASE. Tag sm-mfa-plugin v0.1.1 on its current HEAD, which includes 71d8f27 and the test commits. The tag message names the modes correctly: disabled/optional/enforced. Push master and the tag. Then, in separate commits:
> - sm-demo-app: bump plugins/golem15/mfa to that commit, update go.mod's require to v0.1.1, and go.sum.
> - sm-bm-app: bump the plugins/golem15/mfa gitlink.
> Fetch first and check the range before pushing both. In sm-demo-app, leave the unstaged journal/oauthserver pointer moves alone. Mark the deferred tag-message item resolved.
> 2. F-1: DEFER. Write a todo in .planning/todos/pending/ and record F-1 as deferred in the security review, with the corrected reasoning. I'll mirror it to g15office.
> 3. YES, push the test-only commits on master: sm-user-plugin (3), sm-mfa-plugin (part of item 1) and sm-sociallogin-plugin (1). No new tags for user or social. Never push fonoteka.go.
> No sudo, no force-push and no deploy. Then do Task 6, finalize the SUMMARY and commit it. In the report, list every pushed ref and tag with its SHA.
Carried out as written, with one exception. The fetch and range check before pushing sm-bm-app found commits of another session, so sm-bm-app is committed but not pushed (Remote operations).
## Accomplishments
- **Gate (Task 1).** `scripts/check-phase19.6.sh` copies check-phase19.5.sh's detector, adds the mfa, demo-web and fonoteka module checks, an additive check that also catches removed or changed const and var declarations (gofmt realignment and moves pass), and the device-session migration check. `--self-test` proves every detector on planted output.
- **sm-user-plugin (Task 2).** The gate chain and Login's order (refused first factors never reach a gate; rehash before a challenge; restore and reactivate mail only after the gates; gate answers pass through; refresh never asks a gate); each password-change source fires once in its transaction (a listener's write commits with it, its error rolls both back); the reset action's offer, permission, transaction and rollback; the device flow in every state and refusal, including 50 concurrent polls, 10 concurrent authorizes by two users, the bounded cleanup, a real short-code collision forced by a trigger, revoke after one and two refreshes and the migration's constraints.
- **sm-mfa-plugin (Task 3).** One test per decision of RESEARCH's map (above), the PHP bodies of both route tables, and `TestEveryLoginPathIsGated`, which reads every token mint site of golem15.user and golem15.mfa from source and fails on any unlisted one, then drives each first-factor path of a TOTP user.
- **sm-sociallogin-plugin (Task 3).** `TestSocialGate` over an existing identity, a verified email match and a new account, in token-cookie mode.
- **PHP map, review, validation (Task 4).** `AuthenticationTest.php` has 3 methods: USER-003 maps to `TestStateCookie` (sociallogin, adapted), USER-004 is not applicable (no PIN routes in Go), USER-005 to `TestPHPUser005`. The review maps T-19.6-01..38 and the four SC rows, has the D-14 and D-18 sections, ASVS notes, a log sweep, residual risks and F-1.
## Deviations from Plan
**1. [Rule 1 - Bug] Settings form disabled every method (sm-mfa-plugin `71d8f27`).** See "Defects found and fixed". A production change after the release, hence the Post-release fixes row and the Task 5 stop.
**2. [Rule 3 - Blocking] updates TestMain short detection (sm-user-plugin `2c68979`).** Test-only; needed so the userplugin stage's `-short` run is Docker-free as the gate's NOT RUN rule assumes.
**3. [Rule 4 - raised, deferred] Finding F-1, activate-by-code.** Reproduced through the mfa harness (`require_activation: false`, `activate_mode: user`: password login, TOTP enabled, activate-by-code answers a token without a challenge). Plan 01 accepted this path because "a not-yet-activated account cannot have 2FA yet", which is false in that configuration. Fixing it changes a recorded decision and an endpoint's answer and needs a user-plugin release, so it went to the Task 5 decision. The user deferred it: todo `2026-10-26-activate-by-code-second-factor.md`, security review "Deferred findings" with the corrected reason.
**4. [Carry-forward] D-07 follows the code.** `TestPasswordChangedSources` listens on `AccessRevokedEvent` and checks `PasswordChanged()` (no `PasswordChangedEvent` exists). The administrator's save fires from `FormBeforeUpdate`, before cabana writes the record in the same transaction, so a listener reads the old hash there; the test pins that and proves the transaction with a marker write that commits or rolls back with the password.
**5. [Carry-forward] Migration and base.** The gate allows `202610260001_create_device_auth_sessions` (the plan's `202610210002` is named in a comment); the additive base is `50da92c` (v0.1.11), overridable with `PHASE196_USER_BASE`.
**6. [Plan text vs code] An enabled webauthn row without a credential passes the gate.** The plan said such a user is "challenged with available_methods [] as PHP"; plan 02 deviation 7 chose to challenge only with a usable method. `TestGate/no_usable_method_passes` pins the code.
**7. [Adapted] Success bodies compared by keys.** `TestPHPErrorBodies` compares every error and 422 body byte for byte, but multi-key success bodies by key set and fixed values: Go maps emit sorted keys, PHP kept insertion order. Recorded as an observation (security review, deferred-items.md), not changed.
**8. [Adapted] Management 401 bodies.** An unauthenticated management call is answered by golem15.user's jwt.auth (`{"error":true,"message":"Token not provided"}`, `"Wrong number of segments"`, `"User not found"`) before the handler, so the handler's `{"error":"Unauthorized"}` is unreachable; the test pins the reachable bodies. Likewise surf checks the `{id}` digit constraint inside the middleware chain, so without a session a non-numeric id gets 401; with a session it gets the router's 404 (`TestWebAuthn`).
**9. [Adapted] Short-code input.** Surrounding whitespace is trimmed from the field; inner spaces count toward the 8-character check (422), as PHP's hyphen-only strip did. Pinned in `TestDeviceAuthorize`.
**10. [Test design] Dry-run GORM handle.** `TestWebAuthnShapes` runs the plugin's own `BeginRegistration` and `FinishRegistration` on a dry-run dialector (gorm's own packages, no new module), so the creation options and the single-use state are pinned without Postgres.
**11. [Scope] fonoteka stage runs every workspace module.** The root `./...` covers only the root module (plan 03 found this), so the stage also vets and short-tests `plugins/golem15/{feedback,fonoteka,golem,user}`.
**12. [Environment] sm-user-plugin checkout was on a detached HEAD at `3b22134`.** The first test commit landed detached; `master` (at `3b22134`, the same commit) was checked out and fast-forwarded to it. No history changed.
**13. [Orchestrator update] sm-mfa-plugin is public.** The gate needs no credential note; deferred-items.md marks the private-repository item resolved.
**14. [Range check, Task 6] sm-bm-app not pushed.** Its unpushed range includes six phase 03.2 planning commits of another session (one made on top of my bump while I worked). The reply asked for a range check before pushing, and that check failed, so the bump `fe4e292` is committed but unpushed. To publish it: `git -C sm-bm-app push origin master`, once that session's commits may go out too.
**Total deviations:** 14 (1 bug fixed in production and released, 1 test-harness fix, 1 finding raised and deferred, 2 carry-forwards, 1 plan-text mismatch, 3 adapted assertions, 1 test design, 1 scope, 2 environment, 1 push held back by the range check).
## Known Stubs
None.
## Threat Flags
None: plan 04 adds tests, a script and documents; the one production change narrows behaviour to what the README documents.
## Issues and follow-ups
- **F-1:** deferred; todo `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md` (the user mirrors it to g15office). The corrected reason is in the security review "Deferred findings".
- **sm-bm-app push:** pending, see deviation 14.
- **deferred-items.md** gains three items:
- the `classes` package of sm-user-plugin starts Postgres under `-short` (predates the phase; same fix as `2c68979`);
- the JSON key-order observation;
- F-1, deferred.
It marks two items resolved: the private repository (made public) and the v0.1.0 tag message (v0.1.1 names the modes correctly).
- **fonoteka.go:** the user submodule sits three pushed test commits ahead of its committed pointer. No release is needed; bump it only if wanted.
- **Changelog:** CLAUDE.md asks for a summercms.io changelog post per release tag of the framework (`vX.Y.Z` of summercms.go). v0.1.1 is a plugin tag, so no post was made. The user decides if one is wanted.
## g15office
This executor has no g15office tool. The orchestrator posts the plan 04 update on milestone task `avoroeb4`: gate all 11 stages PASS with Docker; sm-mfa-plugin v0.1.1 released; F-1 deferred (the user mirrors the todo).
## Self-Check: PASSED
- Commits exist: summercms.go `947009a`, `e9785f7`, `531e7ab`, `ea4680e`, `692beb5`; sm-user-plugin `6844d36`, `2c68979`, `1f3266a`; sm-mfa-plugin `71d8f27`, `8ec140e`, `2aff160`, `ab886f8`, `d08f1ec`; sm-sociallogin-plugin `14d5f93`.
- Files exist: `scripts/check-phase19.6.sh` (executable), `19.6-SECURITY-REVIEW.md`, the test files listed in key-files, `../sm-mfa-plugin/testdata/PHP_METHOD_MAP.md` and `php_methods.txt`.
- Acceptance greps of Tasks 1-4 pass (gate: `PHASE196_REQUIRE_DOCKER` 7, mode lines 12, `TestWebAuthnCeremonySmoke` 2, `202610210002_create_device_auth_sessions` 2; review: T-19.6 references 44, `D-14` 4; validation: `nyquist_compliant: true` 1, `check-phase19.6.sh` 10).
- Task 6:
- Commits `9166a1e` (sm-demo-app) and `fe4e292` (sm-bm-app) exist.
- The tag v0.1.1 and every pushed head match `ls-remote`.
- The todo file exists.
- Working trees:
- summercms.go keeps only other sessions' `STATE.md`, `state.json` and untracked paths.
- The three plugin repositories are clean and even with origin.
- sm-demo-app keeps only the leftover journal and oauthserver moves.

View File

@@ -0,0 +1,16 @@
# Phase 19.6 code review disposition
Review: 19.6-REVIEW.md (1 critical, 8 warning, 10 info). Fixes: 19.6-REVIEW-FIX.md. User decision 2026-10-26: fix the critical and all warnings, release, defer what needs a contract change.
| ID | Severity | Status | Note |
|---|---|---|---|
| CR-01 | critical | fixed | sm-user-plugin cc3636c, released v0.1.13 |
| WR-01 | warning | fixed | sm-user-plugin 07196a4, released v0.1.13 (auth_url default and User-Agent-bound poll in release notes) |
| WR-02 | warning | fixed | sm-mfa-plugin eb555e8, released v0.1.2 |
| WR-03 | warning | fixed | sm-user-plugin 557c03c, released v0.1.13 |
| WR-04 | warning | fixed | sm-user-plugin 20e7b17, released v0.1.13 |
| WR-05 | warning | deferred | partial mitigation sm-mfa-plugin 463c750 (v0.1.2); full fix needs an API change: todo 2026-10-26-mfa-factor-changes-need-reauth.md, g15office krt3vrts |
| WR-06 | warning | fixed | sm-mfa-plugin 0a08070, released v0.1.2 |
| WR-07 | warning | fixed | sm-mfa-plugin f71d35d, released v0.1.2 |
| WR-08 | warning | fixed | sm-sociallogin-plugin ecfb8a7, released v0.1.5 |
| IN-01..IN-10 | info | open | outside the fix scope the user chose; listed in 19.6-REVIEW.md |

View File

@@ -0,0 +1,180 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
fixed_at: 2026-10-26T16:40:00Z
review_path: .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-REVIEW.md
iteration: 1
findings_in_scope: 9
fixed: 8
partial: 1
skipped: 0
status: partial
---
# Phase 19.6: Code Review Fix Report
**Fixed at:** 2026-10-26T16:40:00Z
**Source review:** .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-REVIEW.md
**Iteration:** 1
**Scope:** CR-01 and WR-01..WR-08 (critical_warning); the ten info findings were left alone.
**Summary:**
- Findings in scope: 9
- Fixed: 8 (5 of them flagged for human verification of the logic)
- Partial: 1 (WR-05; a full fix needs an API contract change)
- Skipped: 0
`workflow.use_worktrees` is `false`, so every fix was edited, tested and committed in the main checkout of the repository that owns it. Nothing was pushed or tagged. The plugin commits carry no planning IDs and no trailers.
| Finding | Outcome | Repo | Commit | Regression test |
|---------|---------|------|--------|-----------------|
| CR-01 | fixed: requires human verification | sm-user-plugin | cc3636c | `TestDeviceRevokeRacesRefresh` |
| WR-01 | fixed: requires human verification | sm-user-plugin | 07196a4 | `TestDeviceInitiate/answer_and_row`, `TestDeviceAuthorize/token_field_takes_the_short_code`, `TestDeviceHandOverConcurrent/only_the_initiating_client_collects_the_token`, `TestDeviceAuthURL`, `TestDeviceFlowSmoke` |
| WR-02 | fixed: requires human verification | sm-mfa-plugin | eb555e8 | `TestChallengeAfterRevocation` |
| WR-03 | fixed: requires human verification | sm-user-plugin | 557c03c | `TestDeviceAccessRevocation` |
| WR-04 | fixed | sm-user-plugin | 20e7b17 | `TestResetTwoFactorAction/privileged_member_needs_the_privileged_permission` |
| WR-05 | partial | sm-mfa-plugin | 463c750 | `TestPasswordless/password_change_demotes_passkeys` |
| WR-06 | fixed | sm-mfa-plugin | 0a08070 | `TestPasswordConfirmationThrottle` |
| WR-07 | fixed: requires human verification | sm-mfa-plugin | f71d35d | `TestCompletionFailureSpendsNothing`, `TestEveryLoginPathIsGated` (lists updated) |
| WR-08 | fixed: requires human verification | sm-sociallogin-plugin | ecfb8a7 | `TestChallengedSignInFiresOnCompletion` |
Each regression test was checked against the unfixed code (the fix's source files stashed, the test run, the stash restored): every one failed without its fix and passes with it.
## Fixed Issues
### CR-01: A device revoke is lost to a concurrent refresh or a failed jti hand-over
**Files modified:** `fonoteka.go/plugins/golem15/user/classes/device_auth.go`, `controllers/api_controller.go`, `controllers/devices.go`, `device_auth_test.go`, `README.md`
**Commit:** cc3636c (sm-user-plugin)
**Status:** fixed: requires human verification (race and state handling)
**Applied fix:**
- `DeviceDestroy` no longer reads the jti with a plain SELECT. It revokes with `UPDATE … RETURNING jwt_jti` inside the transaction and blacklists the returned jti. That jti is read under the row lock, and a row that is gone answers the same 404 as before.
- The new `classes.FollowDeviceJTI(ctx, db, oldJTI, newJTI) (revoked bool, err error)` makes the guarded hand-over UPDATE (which takes the same row lock). When nothing moved but a session with the old jti exists in another status, it reports `revoked`. `HandOverDeviceJTI` keeps its signature as a wrapper.
- `Refresh` calls the hand-over before stamping last_seen and fails closed:
- a revoked lineage answers 401 with the body bouncer gives a blacklisted token, `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`, and the new token is never sent;
- a hand-over error answers the opaque 500 (it was only logged before).
- Blacklisting the unsent `next` token is unnecessary, because it never leaves the server.
- Whichever side commits second now sees the other. The `blacklist_grace > 0` fork stays the documented residual risk.
### WR-01: The device token in the QR code is also the credential that collects the session JWT
**Files modified:** `fonoteka.go/plugins/golem15/user/classes/device_auth.go`, `config/config.yaml`, `controllers/devices.go`, `device_auth_test.go`, `device_flow_smoke_test.go`, `README.md`
**Commit:** 07196a4 (sm-user-plugin)
**Status:** fixed: requires human verification (default value change, see contract note)
**Applied fix:** the polling secret is kept off the screen without changing any response shape:
- **Template placeholder.** `golem15.user.device_auth.auth_url` supports a new `{short_code}` placeholder (`classes.DeviceAuthURLFor(app, token, shortCode)`; `DeviceAuthURL` keeps its signature). The default is now `{frontend_url}/device/scan/{short_code}`, so `auth_url` (the QR code) carries only the short code.
- **Authorize accepts the short code as `token`.** `POST /devices/authorize` accepts a short code in its `token` field (a 64-character device token never normalizes as one). A scan page that posts whatever its URL carries as `token` therefore works with both templates.
- **Hand-over bound to the initiating client.** `GET /devices/status/{token}` hands the session token only to a poll with the `User-Agent` stored at initiate. Another client gets the existing `{"success":true,"data":{"status":"confirmed"}}` and consumes nothing, so the device still collects its session.
**Contract note:** body shapes and keys are unchanged, but two values and behaviours are new:
- The *value* of `auth_url` changes for apps that do not set `device_auth.auth_url`. sm-demo-app, sm-bm-app and fonoteka.go set no override. I found no frontend in the workspace that calls the device routes.
- The token hand-over now requires the same `User-Agent` at initiate and poll. A device client that initiates through a server-side proxy and polls from the browser (different User-Agents) would wait until expiry. The PHP status route never handed over a token (that is Go's D-12a), so this binding constrains only the new Go contract.
A separate `poll_token` key would be the clean RFC 8628-style fix if the contract may grow.
### WR-02: Challenge completion mints a token for a user banned, or whose password changed, after the password step
**Files modified:** `sm-mfa-plugin/classes/challenge.go`, `classes/admin.go`, `controllers/challenge.go`, `revoked_challenge_test.go`, `README.md`
**Commit:** eb555e8 (sm-mfa-plugin)
**Status:** fixed: requires human verification (logic)
**Applied fix:**
- `attempt` (used by every verify route: totp, email, recovery and webauthn) calls the new `classes.ChallengeRevoked` after loading the user. It refuses with the route's existing generic 401 when:
- the user is banned from any address (`ThrottleStates`);
- the user is suspended at the verifying address (`RejectIfThrottled`, the check `/login` makes);
- the user's `tokens_valid_after` is later than the challenge's `created_at` (compared at whole seconds, like the jwt guard).
- I used suspension at the verifying address rather than suspension anywhere, so a stranger who fails five logins with the victim's email cannot block the victim's second factor.
- `OnAccessRevoked` now runs `ExpireOpenChallenges` on `ev.Tx` for every event, bans included. Trusted devices are still revoked only on a password change.
- `CompleteChallenge` also requires `expires_at > NOW()`, which closes the window between an attempt and its completion.
- Only existing sm-user-plugin v0.1.12 API is used.
### WR-03: Device hand-over ignores `tokens_valid_after`, and a password change leaves confirmed device sessions live
**Files modified:** `fonoteka.go/plugins/golem15/user/classes/device_auth.go`, `controllers/devices.go`, `plugin.go`, `device_auth_test.go`, `README.md`
**Commit:** 557c03c (sm-user-plugin)
**Status:** fixed: requires human verification (logic)
**Applied fix:**
- `handOverDevice` now also refuses (and marks the row `revoked`) when `confirmed_at`, truncated to whole seconds, is before the user's `tokens_valid_after`.
- Boot registers an `AccessRevokedEvent` listener that runs the new `classes.RevokeDeviceSessions` on `ev.Tx` (falling back to the published DB when `Tx` is nil). It sets `status = 'revoked'` for the users' `pending` and `confirmed` sessions. A listener error rolls the write back.
- Used sessions keep their status; their tokens end through `tokens_valid_after`.
### WR-04: Reset 2FA bypasses the privileged-group guard
**Files modified:** `fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `reset_two_factor_test.go`, `README.md`
**Commit:** 20e7b17 (sm-user-plugin)
**Status:** fixed
**Applied fix:**
- The action's `Run` calls `guardPrivilegedMember` before `svc.Reset`. Without `golem15.users.manage_privileged_groups`, a privileged-group member answers cabana's 403 with the new message `privileged_member_reset_two_factor_forbidden` (en and pl), and `Reset` does not run.
- I tried the optional hiding in `Applies`, then dropped it. Cabana answers a hidden action with a generic 409, and the existing password and delete guards show their 403 message rather than hiding the action.
### WR-06: `confirmPassword` is an unthrottled password oracle for a session holder
**Files modified:** `sm-mfa-plugin/controllers/management.go`, `confirm_password_test.go`, `README.md`
**Commit:** 0a08070 (sm-mfa-plugin)
**Status:** fixed
**Applied fix:**
- `confirmPassword` now records every non-empty password check through golem15.user's `CheckAndRecordLogin` for the client address. Five failures suspend the account there, as at `/login`, and a right password resets the count.
- While the account is banned or suspended from any address (`ThrottleStates`), it answers the existing 403 `{"error":"Password confirmation required."}`, even to the right password.
- An empty password is refused without counting.
### WR-07: Failures after the challenge commit consume the challenge and the recovery code without delivering a token
**Files modified:** `sm-mfa-plugin/classes/login.go`, `controllers/challenge.go`, `controllers/helpers.go`, `login_paths_test.go`, `complete_failure_test.go`, `README.md`
**Commit:** f71d35d (sm-mfa-plugin)
**Status:** fixed: requires human verification (transaction ordering)
**Applied fix:**
- **Before the transaction:** `completeSignIn` resolves the settings (`effective`, only when `trust_device` is truthy) and the session cookie (new `sessionCookie`).
- **One transaction:** it then completes the challenge, spends the factor, issues the trusted device on `tx`, and runs the new `classes.FinishSignIn`. That function makes `IssueToken`, then `APIArray`, then golem15.user's `FinishLogin` on `tx`. The payload is built before `FinishLogin` so that no `GetApiArrayEvent` listener can block on the users-row lock that `FinishLogin` takes.
- **After commit:** only then do the login event (new `classes.FireLogin`, source two_factor) and the verified event fire, and the answer is written by the infallible `writeSessionWith`.
- `CompleteLogin` and `CompleteLoginAs` (the passkey path) are unchanged.
- `TestEveryLoginPathIsGated` lists the new mint site `classes/login.go FinishSignIn usercontrollers.IssueToken` and the completion caller `controllers/challenge.go completeSignIn FinishSignIn`, and its call-site regexp now includes `FinishSignIn`.
- Residual: `FinishLogin` sends a deactivated account's reactivate mail before the commit, so a failed commit after it would leave a mail for a restore that rolled back.
### WR-08: A challenged social sign-in never fires `SignedInEvent`
**Files modified:** `sm-sociallogin-plugin/classes/challenged.go` (new), `classes/codes.go`, `classes/flow.go`, `plugin.go`, `challenged_signin_test.go`, `README.md`
**Commit:** ecfb8a7 (sm-sociallogin-plugin)
**Status:** fixed: requires human verification (cross-plugin coupling)
**Applied fix:**
- **Holding the event.** When a gate challenges, `signIn` seals the `SignedInEvent` fields in the cache (`putSealedAt`, a new helper beside `putSealed`). The key is `oauth-2fa-signin:` plus the SHA-256 hex of the gate's `challenge_token`; the entry lives until the challenge's `expires_at`, at most 60 minutes. A cache error fails the sign-in, as the completion code would.
- **Firing it.** Boot registers `classes.OnTwoFactorLogin`, a listener on golem15.user's `LoginEvent`. On source `two_factor` it reads the user's challenges completed in the last 5 minutes from `golem15_user_two_factor_challenges` (golem15.mfa's table, PHP's name), pulls each one's held-back sign-in once, and fires `SignedInEvent` with the original provider, identity, tenant, email, action and created flag.
- **Timing.** The event fires after the 2FA token was minted, so a listener's writes reach later requests but not the completion's own payload. The README documents this.
- **Dependencies.** sociallogin does not import mfa; it only reads that table name. No new sm-user-plugin API is used.
## Partial Issues
### WR-05: Second factors can be removed, replaced or added with a bearer session alone
**File:** `sm-mfa-plugin/controllers/management.go`, `controllers/webauthn.go`, `classes/methods.go`, `classes/admin.go`
**Commit:** 463c750 (sm-mfa-plugin)
**Status:** partial
**Applied mitigation (contract-safe):**
- Every password change (own, reset, admin-set) now runs the new `classes.DemotePasskeys` in the password write's transaction, through `OnAccessRevoked`. It clears `passwordless` on the user's WebAuthn credentials; they stay enabled as second-factor keys. A passkey registered with a stolen session therefore no longer survives the password change that locks the intruder out.
- The trade-off: the user's own passkeys must be registered again as passkeys after any password change, the same treatment trusted devices already get under D-07.
- README "Passkeys" and "Security notes" record the decision and the remaining exposure.
**Not fixed, because it needs a contract change:** PHP's `TwoFactorApiController` requires no password on `totpConfirm`, `totpDisable`, `emailDisable`, `webauthnRegister` or `webauthnRemove`, and the frontends send none. A full fix needs a step-up on:
- `DELETE /2fa/totp`
- `DELETE /2fa/email`
- `DELETE /2fa/webauthn/{id}`
- `POST /2fa/totp/confirm` while TOTP is already enabled
- `POST /2fa/webauthn/register`, at least with `passwordless: true`
The step-up is a `password` field (query or body, as `recovery-codes` takes it), or a current factor, refused with the existing 403 `{"error":"Password confirmation required."}` and counted through the throttle as in WR-06. Every frontend (the demo's `security.vue` and any production client) would have to send it, so it needs the user's decision and a coordinated frontend release. Until then, a stolen session can still disable or replace factors while it lives.
---
## Verification
All checks ran in the **main checkouts** (`workflow.use_worktrees: false`; no worktree was created):
- `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user`
- `/media/nvme/dev/golem15/summercms.io/summercms/sm-mfa-plugin`
- `/media/nvme/dev/golem15/summercms.io/summercms/sm-sociallogin-plugin`
sm-mfa-plugin and sm-sociallogin-plugin build against the local user plugin through their committed `go.work`. Docker was available to the session user, so no sudo was needed.
- Before each commit: `gofmt`, `go vet ./...` and the repository's full `go test ./...` (Postgres testcontainers included) were green.
- `scripts/check-phase19.6.sh --userplugin`, `--mfa`, `--sociallogin` and `--docs` all passed (run one stage per invocation; the script takes a single mode). The userplugin additive check reported no exported declaration removed or changed since 50da92c.
- Final run after the last commit: `go vet ./...` and `go test -count=1 ./...` (Docker included) green in sm-user-plugin (root 89s, classes 48s, models, updates), sm-mfa-plugin (47s) and sm-sociallogin-plugin (43s). `go build ./...` of the fonoteka.go workspace, which uses the changed user plugin, also succeeds.
_Fixed: 2026-10-26T16:40:00Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_

View File

@@ -0,0 +1,354 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
reviewed: 2026-10-26T14:43:47Z
depth: standard
files_reviewed: 65
files_reviewed_list:
- fonoteka.go/plugins/golem15/user/classes/login_gate.go
- fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go
- fonoteka.go/plugins/golem15/user/classes/device_auth.go
- fonoteka.go/plugins/golem15/user/classes/events.go
- fonoteka.go/plugins/golem15/user/controllers/login_gates.go
- fonoteka.go/plugins/golem15/user/controllers/devices.go
- fonoteka.go/plugins/golem15/user/controllers/api_controller.go
- fonoteka.go/plugins/golem15/user/controllers/registration.go
- fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
- fonoteka.go/plugins/golem15/user/models/device_auth_session.go
- fonoteka.go/plugins/golem15/user/updates/202610260001_create_device_auth_sessions.go
- fonoteka.go/plugins/golem15/user/admin_permissions.go
- fonoteka.go/plugins/golem15/user/plugin.go
- fonoteka.go/plugins/golem15/user/routes.go
- fonoteka.go/plugins/golem15/user/config/config.yaml
- sm-mfa-plugin/plugin.go
- sm-mfa-plugin/routes.go
- sm-mfa-plugin/buckets.go
- sm-mfa-plugin/mail.go
- sm-mfa-plugin/admin_permissions.go
- sm-mfa-plugin/admin_settings.go
- sm-mfa-plugin/classes/admin.go
- sm-mfa-plugin/classes/challenge.go
- sm-mfa-plugin/classes/config.go
- sm-mfa-plugin/classes/email.go
- sm-mfa-plugin/classes/events.go
- sm-mfa-plugin/classes/gate.go
- sm-mfa-plugin/classes/login.go
- sm-mfa-plugin/classes/methods.go
- sm-mfa-plugin/classes/passwordless.go
- sm-mfa-plugin/classes/qr.go
- sm-mfa-plugin/classes/recovery.go
- sm-mfa-plugin/classes/totp.go
- sm-mfa-plugin/classes/trusted.go
- sm-mfa-plugin/classes/webauthn.go
- sm-mfa-plugin/controllers/challenge.go
- sm-mfa-plugin/controllers/helpers.go
- sm-mfa-plugin/controllers/management.go
- sm-mfa-plugin/controllers/passwordless.go
- sm-mfa-plugin/controllers/webauthn.go
- sm-mfa-plugin/models/json_map.go
- sm-mfa-plugin/models/registry.go
- sm-mfa-plugin/models/settings.go
- sm-mfa-plugin/models/string_list.go
- sm-mfa-plugin/models/trusted_device.go
- sm-mfa-plugin/models/two_factor_challenge.go
- sm-mfa-plugin/models/two_factor_method.go
- sm-mfa-plugin/models/two_factor_recovery_code.go
- sm-mfa-plugin/models/webauthn_credential.go
- sm-mfa-plugin/updates/202610220001_create_golem15_user_two_factor_methods.go
- sm-mfa-plugin/updates/202610220002_create_golem15_user_two_factor_challenges.go
- sm-mfa-plugin/updates/202610220003_create_golem15_user_two_factor_recovery_codes.go
- sm-mfa-plugin/updates/202610220004_create_golem15_user_trusted_devices.go
- sm-mfa-plugin/updates/202610220005_create_golem15_mfa_settings.go
- sm-mfa-plugin/updates/202610220006_create_golem15_user_webauthn_credentials.go
- sm-mfa-plugin/updates/registry.go
- sm-mfa-plugin/config/config.yaml
- sm-sociallogin-plugin/classes/flow.go
- sm-sociallogin-plugin/classes/codes.go
- sm-sociallogin-plugin/controllers/callback.go
- sm-sociallogin-plugin/controllers/complete.go
- sm-sociallogin-plugin/controllers/register_complete.go
- sm-demo-app/vue-demo-app/app/lib/twoFactor.ts
- sm-demo-app/vue-demo-app/app/composables/useTwoFactor.ts
- sm-demo-app/vue-demo-app/app/composables/useDemoAuth.ts
- sm-demo-app/vue-demo-app/app/pages/account/security.vue
- sm-demo-app/vue-demo-app/app/pages/login.vue
findings:
critical: 1
warning: 8
info: 10
total: 19
status: issues_found
---
# Phase 19.6: Code Review Report
**Reviewed:** 2026-10-26T14:43:47Z
**Depth:** standard
**Files Reviewed:** 65 (paths relative to `/media/nvme/dev/golem15/summercms.io/summercms/`)
**Status:** issues_found
## Summary
I reviewed the production code of phase 19.6: sm-user-plugin `50da92c..1f3266a` (login gates, the login event, device authorization, the Reset 2FA action), every non-test file of the new sm-mfa-plugin, the social-login gate call in sm-sociallogin-plugin (`a8e3e78^..HEAD`), and the demo frontend files from `44a5229`. I read the tests only to understand intent. I did not re-report anything 19.6-SECURITY-REVIEW.md already records (F-1, D-12c phishing, the `blacklist_grace` fork, proxy logging of the status URL, the memory cooler, the numeric user handle, key order, accepted T-19.6-25).
Several things hold up. The challenge lifecycle is correct: the attempt counter is a guarded increment, completion is one guarded UPDATE in the same transaction that consumes the factor, the TOTP step is a guarded counter, and recovery codes are consumed with a guarded UPDATE. Secrets at rest are hashed or encrypted. Short codes use rejection sampling. TOTP compares in constant time (pquerna `hotp`), and recovery and email codes are compared with bcrypt. The WebAuthn RP ID and origins come from configuration, never from the request. Every sm-user mint site except F-1 is gated or carries its own proof.
The problems are at the edges of the flows:
- A device revoke can be lost to a concurrent refresh, or to a hand-over write that fails and is only logged. A revoked device then keeps refreshing indefinitely (CR-01).
- The QR token shown on the waiting device is also the secret that collects the session JWT (WR-01).
- Challenge completion re-checks neither a ban nor `tokens_valid_after`. The device hand-over does not check `tokens_valid_after` either (WR-02, WR-03).
- Reset 2FA skips the privileged-group guard that password and delete edits enforce (WR-04).
- Second factors can be removed, replaced or added with a bearer session alone. With passkey sign-in on, a stolen 60-minute JWT becomes a permanent login that survives a password change (WR-05).
## Narrative Findings (AI reviewer)
## Critical Issues
### CR-01: A device revoke is lost to a concurrent refresh or a failed jti hand-over, and the revoked device keeps refreshing
**File:** `fonoteka.go/plugins/golem15/user/controllers/devices.go:483-506`, `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:276,315-321`, `fonoteka.go/plugins/golem15/user/classes/device_auth.go:137-144`
**Issue:** `DeviceDestroy` reads `row.JWTJTI` with a plain SELECT (line 484), outside its transaction and with no row lock. It then blacklists that value (line 506). `Refresh` mints the new token first (`bouncer.Refresh`, line 276, which with the default grace of 0 also blacklists the old jti). Only after that does it move the device row to the new jti with `HandOverDeviceJTI`, whose UPDATE requires `status = 'used'`.
Here is how the race goes:
1. Revoke reads jti A.
2. A refresh verifies A, mints B and runs `UPDATE … SET jwt_jti = B WHERE jwt_jti = A AND status = 'used'`, which succeeds.
3. Revoke marks the row revoked and blacklists A, which is already dead.
The device now holds B, which is not blacklisted. Its next refresh (B to C) finds no `used` row, so `HandOverDeviceJTI` changes nothing and nobody ever blacklists the lineage. The reverse order (revoke commits between `bouncer.Refresh` and the hand-over) gives the same result: the UPDATE matches no row and `next` is returned anyway.
There is also a deterministic fail-open path that needs no race. Any error from `HandOverDeviceJTI` is only logged ("never fails the refresh", lines 309-320). After one transient database error, the row keeps a stale jti forever, and a later revoke blacklists a token the device no longer uses. The revoked or stolen device stays signed in, and keeps refreshing, until a password change.
T-19.6-08 is marked closed, but its tests cover only sequential refresh and revoke. A device holder that refreshes in a tight loop widens the window.
**Fix:** Make both sides atomic and fail closed:
```go
// DeviceDestroy: read the jti that is current at commit time
var jti *string
err = lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Raw(`UPDATE `+deviceTable+` SET status = 'revoked', updated_at = NOW()
WHERE id = ? AND user_id = ? RETURNING jwt_jti`, row.ID, user.ID).Scan(&jti).Error; err != nil {
return err
}
... // blacklist *jti
})
// Refresh: lock the device row by the old jti before minting, refuse a revoked lineage,
// and fail the refresh (or blacklist `next`) when the hand-over UPDATE errors or
// matches a row that is no longer 'used'.
```
At minimum, give `HandOverDeviceJTI` a signal that a row with `jwt_jti = old` exists in status `revoked`. When it does, blacklist `next` and answer the refresh 401, and treat an error from it as a refresh failure.
## Warnings
### WR-01: The device token in the QR code is also the credential that collects the session JWT
**File:** `fonoteka.go/plugins/golem15/user/classes/device_auth.go:96-105`, `fonoteka.go/plugins/golem15/user/controllers/devices.go:147-153,170-198,360-361`
**Issue:** `DeviceInitiate` builds `auth_url` (the QR code the device displays) from the same `token` that `GET /devices/status/{token}` accepts to hand over the session JWT. Whoever sees the QR code or the scan URL (a photo of the screen, browser history, a Referer or analytics script on the `/device/scan/{token}` page, a QR scanner app) can poll the status route. The first poll after the victim confirms receives `{"token": …}`, and the guarded UPDATE makes the legitimate device lose with `{"status":"used"}`.
This is not the phishing case D-12c accepts. The victim authorizes their own device and the observer takes the session. RFC 8628 keeps the polled `device_code` separate from the displayed `user_code` for this reason. The security review's T-19.6-09 covers storage and logs only.
**Fix:** Keep the polling secret off the screen. Without changing the existing response shapes:
- support a `{short_code}` placeholder in `device_auth.auth_url` and make it the default, so the QR code carries only the short code that `DeviceAuthorize` already accepts;
- additionally bind the hand-over to the initiating client, for example by refusing a poll whose user agent differs from `device_user_agent`.
A separate `poll_token` returned only by `initiate` is the clean fix if the contract can grow a key.
### WR-02: Challenge completion mints a token for a user banned, or whose password changed, after the password step
**File:** `sm-mfa-plugin/controllers/challenge.go:62-96,296-327`, `sm-mfa-plugin/classes/login.go:355-391`
**Issue:** The ban and suspension checks run once, at `/login` (`RejectIfThrottled`). The challenge stays usable for `two_factor_challenge_ttl` (up to 60 minutes through settings). Neither `attempt` nor `completeSignIn` re-checks `ThrottleStates`, and neither compares the challenge's `created_at` with the user's `tokens_valid_after`.
A ban, or a password reset meant to lock out an attacker who has the password and a second factor (for example a stolen recovery code), does not invalidate the pending challenge. Its completion mints a token with `iat` after the revocation cutoff, which the jwt guard accepts (`PrincipalByID` checks no ban). `/refresh` would refuse the user, but the token is good for its full TTL.
The passkey path (`passwordless.go:118-131`) and the device hand-over (`devices.go:255-261`) both check bans, so this path is the inconsistent one.
**Fix:** In `attempt`, after `ChallengeUser`, refuse with the method's generic 401 when:
```go
banned, suspended, err := userclasses.ThrottleStates(userclasses.ContextWithThrottle(ctx, app), db, []uint{user.ID})
if banned[user.ID] || suspended[user.ID] ||
(user.TokensValidAfter != nil && ch.CreatedAt != nil && user.TokensValidAfter.After(*ch.CreatedAt)) { refuse(w); return ... }
```
Also delete the user's open challenges in `OnAccessRevoked` (`classes/admin.go:471-484`) on the event's transaction.
### WR-03: Device hand-over ignores `tokens_valid_after`, and a password change leaves confirmed device sessions live
**File:** `fonoteka.go/plugins/golem15/user/controllers/devices.go:245-271`
**Issue:** `handOverDevice` refuses only a missing, soft-deleted or banned user. A user who authorized a device and then changes the password (or an admin who sets one), intending to end every session, still has the `confirmed` row live until `expires_at`. The next poll mints a fresh JWT whose `iat` is after the cutoff. No `AccessRevokedEvent` listener touches `golem15_user_device_auth_sessions`.
**Fix:** In `handOverDevice`, treat `user.TokensValidAfter != nil && row.ConfirmedAt != nil && user.TokensValidAfter.After(*row.ConfirmedAt)` as refused (mark the row revoked). Also add an `AccessRevokedEvent` listener in sm-user-plugin that runs, on `ev.Tx`:
```sql
UPDATE golem15_user_device_auth_sessions SET status = 'revoked'
WHERE user_id IN ? AND status IN ('pending', 'confirmed')
```
### WR-04: Reset 2FA bypasses the privileged-group guard
**File:** `fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go:802-827` (compare `193-210`, `537`, `549`)
**Issue:** On the Users screen, setting the password of a privileged-group member (whose group "makes a site administrator") or deleting one requires `golem15.users.manage_privileged_groups` (`guardPrivilegedMember`). The new `reset_two_factor` action's `Run` calls `svc.Reset` directly and requires only `golem15.users.reset_two_factor`. An administrator granted only the reset permission (for example a support role) can strip every second factor, trusted device and passkey from a privileged account. That leaves the account behind its password alone, which is the downgrade the privileged guard exists to prevent.
**Fix:** Run the same guard before the reset:
```go
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
if err := c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_forbidden", nil); err != nil {
return err
}
...
return svc.Reset(ctx, tx, user)
}
```
Optionally, also hide the action in `Applies` for privileged members when the principal lacks `PermissionManagePrivilegedGroups`.
### WR-05: Second factors can be removed, replaced or added with a bearer session alone; a stolen JWT becomes a permanent passkey login
**File:** `sm-mfa-plugin/controllers/management.go:87-128,152-171,176-208`, `sm-mfa-plugin/controllers/webauthn.go:88-195,202-243`, `sm-mfa-plugin/classes/methods.go:79-85`, `sm-mfa-plugin/classes/admin.go:471-484`
**Issue:** Only recovery codes ask for the password (`confirmPassword`). The following need nothing beyond `jwt.auth`:
- `DELETE /2fa/totp`, `DELETE /2fa/email` and `DELETE /2fa/webauthn/{id}` disable factors.
- `POST /2fa/totp/confirm` overwrites an already enabled TOTP secret: `EnableMethod` runs `ON CONFLICT … DO UPDATE SET secret = EXCLUDED.secret`, without proof of the current factor.
- `POST /2fa/webauthn/register` adds credentials.
With `two_factor_passwordless_login` on, anyone holding a session JWT for 60 minutes (XSS, a leaked token, a device obtained through WR-01) can register their own passkey with `passwordless=true`. `OnAccessRevoked` revokes only trusted devices, so that passkey keeps signing in without a password or a challenge after the victim changes the password. T-19.6-25 accepted a client-chosen secret for the caller's own first setup, not the overwrite of an enabled factor or the persistence path.
**Fix:**
- Require `confirmPassword` (or a fresh factor) on factor removal, on TOTP confirm when a TOTP method is already enabled, and on WebAuthn registration (at least when `passwordless` is requested).
- On a password change, either delete `passwordless` credentials or record the change and flag credentials created since the last password change, as the README's threat notes decide.
### WR-06: `confirmPassword` is an unthrottled password oracle for a session holder
**File:** `sm-mfa-plugin/controllers/management.go:212-224`
**Issue:** A wrong password on `GET /2fa/recovery-codes?password=` or on `regenerate` answers 403 without recording a failed attempt in golem15.user's throttle. Those routes allow 120 requests a minute per user (`user-api`, keyed by the bearer subject). A stolen session can therefore brute-force the account password at about 170,000 guesses a day without ever triggering the login suspension. Recovering the password then defeats the password half of every later login.
**Fix:** Count a failed confirmation through the user plugin's throttle (export a helper around `refuseLogin`'s recording, or call `classes.CheckAndRecordLogin(ctx, db, user, ip, false)`). Refuse while the account is suspended. Alternatively, give the two routes a tight per-user bucket (for example 5 a minute).
### WR-07: Failures after the challenge commit consume the challenge and the recovery code without delivering a token
**File:** `sm-mfa-plugin/controllers/challenge.go:296-351`, `sm-mfa-plugin/controllers/helpers.go:569-582`
**Issue:** `completeSignIn` commits the completed challenge (and the consumed recovery code or TOTP step) first. After that it calls:
1. `CompleteLogin`: `FinishLogin` restores the account and stamps it, the token is minted, and `LoginEvent` fires.
2. The `VerifiedEvent`.
3. `withTrustedDevice`, which runs `effective` and `IssueTrustedDevice` (an INSERT).
4. `writeSession`, which may fail on `bouncer.LoadCookieConfig`.
Any error in steps 3 or 4 answers the opaque 500 after the login event fired and a token was minted but never sent. The user has to sign in again, and when the factor was a recovery code, that single-use code is gone. An error in step 1 burns the code the same way. Audit listeners see a `golem15.user.login` for a sign-in that never happened.
**Fix:**
- Resolve the cookie config and `effective` before the transaction.
- Create the trusted-device row inside the completing transaction (it only needs `db`).
- Mint and fire events only after everything that can fail has succeeded.
- For a failure inside `CompleteLogin`, consider rolling the recovery-code consumption into the same transaction as `FinishLogin`.
### WR-08: A challenged social sign-in never fires `SignedInEvent`, so its listeners are skipped for 2FA users
**File:** `sm-sociallogin-plugin/classes/flow.go:340-353`
**Issue:** When the gate challenges, `signIn` returns before `fireSignedIn`. The second factor's completion (`mfa CompleteLogin`) fires only golem15.user's `LoginEvent` with source `two_factor`, which carries no provider, identity or tenant. Any application listener on `SignedInEvent` never runs for users who enabled a second factor while `two_factor_social_login` is on. The plugin README cites one such listener: an organisation assigned by tenant, whose write is meant to reach the token. The README documents the skip, but no replacement event exists, so the listener's effect is silently lost on exactly the accounts with the strongest protection.
**Fix:** Store the pending `SignedInEvent` fields with the challenge (the completion payload already goes through the sealed cache), and fire `SignedInEvent` on completion. One way is a `LoginEvent` listener in sociallogin that recognises a challenge it created, for example keyed by challenge id. Another is a documented hook in mfa's `CompleteLogin`. At minimum, state in the README that `SignedInEvent` listeners must also handle `LoginEvent{Source: two_factor}`.
## Info
### IN-01: The email-send cap is hard-coded in SQL next to an unused constant
**File:** `sm-mfa-plugin/classes/email.go:25,83`
**Issue:** `MaxEmailSends = 3` is documented as the cap, but the guarded UPDATE says `email_sends < 3` literally. Changing the constant would silently not change the behaviour.
**Fix:** Bind the constant: `… AND email_sends < ?`, passing `MaxEmailSends`.
### IN-02: `two_factor_email_code_ttl` changes only the mail text; the code lives as long as the challenge
**File:** `sm-mfa-plugin/classes/email.go:111-136`, `sm-mfa-plugin/config/config.yaml` (`two_factor_email_code_ttl`)
**Issue:** `VerifyEmailCode` never checks the send time. Settings allow a challenge TTL of up to 60 minutes and a code TTL of 1 minute, and the code then works for the full hour although the mail promised one minute.
**Fix:** Store the send instant (or `code_expires_at`) in the guarded UPDATE and refuse after `EmailCodeMinutes`.
### IN-03: Confirmed device sessions that are never polled are never expired, cleaned or hidden
**File:** `fonoteka.go/plugins/golem15/user/controllers/devices.go:97-100,385-392,431-434`
**Issue:** A row only becomes `expired` when it is polled or re-authorized, and the cleanup deletes only `pending` and `expired` rows. A `confirmed` row whose device went away therefore stays forever, appears in `GET /devices` as an authorized device, and keeps its unique short code. Line 388 also discards the error of the expiry UPDATE.
**Fix:** Include `status = 'confirmed' AND expires_at < NOW() - INTERVAL '1 hour'` in the cleanup, and filter expired confirmed rows out of `DeviceIndex`, or mark them expired there. Log the line-388 error.
### IN-04: A short code typed with a space answers 422 although the doc says spaces are optional
**File:** `fonoteka.go/plugins/golem15/user/controllers/devices.go:312-313,343-348`
**Issue:** The length check runs after removing hyphens only, so "ABCD EFGH" (9 bytes) is refused before `NormalizeDeviceShortCode`, which would accept it.
**Fix:** Strip spaces as well before the length check, or make the doc comment match PHP's behaviour.
### IN-05: The default WebAuthn RP ID comes from `app.url`, contradicting the configuration's own guidance
**File:** `sm-mfa-plugin/classes/config.go:178-184`
**Issue:** The config comment says the RP ID must be the host of the frontend pages. With an empty `rp_id`, though, the default is the API host (`app.url`). When `golem15.user.frontend_url` is another host, every ceremony fails in the browser. This fails closed, but it is a confusing default.
**Fix:** Default to the `frontend_url` host when it is set, and refuse at boot (or log) an origin whose host is neither the RP ID nor a subdomain of it.
### IN-06: `MaskEmail` slices bytes and can split a multi-byte character
**File:** `sm-mfa-plugin/classes/email.go:41-51`
**Issue:** `local[:1]` and `local[len(local)-1:]` cut UTF-8 sequences in internationalised local parts, which produces U+FFFD in the JSON answer.
**Fix:** Operate on `[]rune(local)`.
### IN-07: A gate result with a nil Body is answered as `null`
**File:** `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:150-156`
**Issue:** sociallogin normalizes a nil `gate.Body` to `{}` (`flow.go:349-352`), but `Login` writes it as is.
**Fix:** Apply the same `if body == nil { body = map[string]any{} }` before `writeJSON`.
### IN-08: The second-factor WebAuthn assertion is not held to the 60-second ceremony timeout
**File:** `sm-mfa-plugin/classes/webauthn.go:453-460`
**Issue:** `FinishAssertion` sets `session.Expires` to the challenge's `expires_at` (5 minutes or more). The enforced `WebAuthnTimeout` therefore applies only to registration and passkey state, not to the login step.
**Fix:** Store the options' issue time with the challenge, and use `min(issued+WebAuthnTimeout, ch.ExpiresAt)`.
### IN-09: The demo login cannot complete an email-only or WebAuthn-only challenge
**File:** `sm-demo-app/vue-demo-app/app/pages/login.vue:21,35,98`
**Issue:** Without `totp` in `available_methods`, the page falls back to recovery-code mode and hides the switch. A user whose only method is email can sign in only by spending a recovery code.
**Fix:** Add the `email/send` and `verify method=email` step, or show a message that the method is not supported in the demo.
### IN-10: Passkey sign-in refuses non-activated accounts even when activation is not required
**File:** `sm-mfa-plugin/classes/passwordless.go:115-117`
**Issue:** `Login` refuses a not-activated account only while `golem15.user.activation.require_activation` is on. The passkey path refuses it unconditionally. This fails closed, but the two sign-ins are inconsistent.
**Fix:** Use the user plugin's registration settings (`RegistrationSettings`) for the same check, or document the stricter rule.
---
_Reviewed: 2026-10-26T14:43:47Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,166 @@
# Phase 19.6 Security Review: second factors and device login
**Date:** 2026-10-26
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain"), block on high; D-14 asks for an explicit review of passwordless passkey login.
**Scope:** this phase's sm-user-plugin additions (login gates, the login event, the password-change hook, the Reset 2FA slot, device authorization), the new `sm-mfa-plugin` (gate, challenge store, TOTP and QR, recovery and email codes, failure cap, trusted devices, settings, admin reset, WebAuthn second factor, passwordless passkeys), the social-login gate call in `sm-sociallogin-plugin`, the adoption in sm-demo-app, sm-bm-app and fonoteka.go, and this plan's gate.
**Threats reviewed:** T-19.6-01..T-19.6-38 and the four T-19.6-SC rows of the threat models of plans 01, 02, 03 and 04, plus RESEARCH's Security Domain table and Assumptions A1-A13.
**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason. One defect found by the new tests was fixed in its own commit (sm-mfa-plugin `71d8f27`, T-19.6-24). One new finding, **F-1** (activate-by-code mints a token without the second factor when `require_activation` is off), corrects the reason plan 01 recorded for an accepted mint path; the user deferred it at plan 04's Task 5 (todo `2026-10-26-activate-by-code-second-factor.md`). T-19.6-37 is closed: the fix shipped as sm-mfa-plugin v0.1.1 and every pin contains it.
Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **M** = `sm-mfa-plugin`, **S** = `sm-sociallogin-plugin`, **D** = sm-demo-app (with `vue-demo-app`), **B** = sm-bm-app, **A** = the fonoteka.go workspace.
## Method
1. For each threat, read the code that implements the mitigation and find the test that fails without it. Where a test was missing, plan 04 added it (Tasks 2 and 3) in the owning repository.
2. Mutation checks on the race- and security-sensitive mitigations, each reverted after the run:
- M `classes/challenge.go` `CompleteChallenge` without `AND completed_at IS NULL`: `TestChallengeLifecycle` fails (racing TOTP and recovery verifications mint more than one token).
- M `classes/email.go` `SendEmailCode` without `AND email_sends < 3`: `TestEmailCode` fails.
- M `classes/gate.go` with social sign-ins always allowed: `TestGate` and `TestEveryLoginPathIsGated` fail.
- M `classes/passwordless.go` `passkeyUser` without the `passwordless` column check: `TestPasswordless` fails.
- M `models/settings.go` without the empty-list normalization: `TestSettings` fails.
- U `controllers/devices.go` hand-over UPDATE without `AND status = 'confirmed'`: `TestDeviceHandOverConcurrent` fails.
- U `controllers/devices.go` authorize UPDATE without the pending-only and `user_id` guard: `TestDeviceAuthorize` fails.
- The PHP map: a renamed Go test, a renamed subtest and an extra listed method each fail `TestPHPMethodMap`.
3. Sweeps: every non-test log call of M and of U's device controller (section "Log calls"); every token mint site of U and M (`TestEveryLoginPathIsGated/mint_sites`); the response bodies of RESEARCH Q4's two tables (`TestPHPErrorBodies`).
4. Run the whole gate: `scripts/check-phase19.6.sh --all` on a Docker host.
## Defects fixed during this plan
| Commit | Repo | Threat | Found by | Fix |
|--------|------|--------|----------|-----|
| 71d8f27 | M | T-19.6-24 | `TestSettings/form_round_trip_keeps_the_configuration` | The settings form shows a NULL `two_factor_available_methods` as an empty taglist and sends it back as `[]` on every save, so saving the form once (for example to turn trusted devices on) stored `[]` and made every method unavailable: TOTP setup answered 403, and no user could enable a second factor. An empty list is now an empty value: `Settings.BeforeValidate` stores it as NULL and the overlay ignores an empty stored list (methods and enforce groups), as the README already promised. README and the en and pl comments updated. |
The fix is a production change after plan 03's release (sm-mfa-plugin v0.1.0); under the Task 5 reply it shipped as sm-mfa-plugin v0.1.1 (`d08f1ec`), pinned by sm-demo-app `9166a1e` (gitlink and go.mod) and sm-bm-app `fe4e292` (T-19.6-37).
## Threat register
| Threat | Component | Mitigation as built | Implementation | Test (repo) | Status |
|--------|-----------|---------------------|----------------|-------------|--------|
| T-19.6-01 | login gate consultation | `Login` asks `CheckLoginGates` after the ban check, password, activation, throttle record and rehash; a result returns its status and body before restore, last_seen, mint and event; a gate error answers the opaque 500. | U `controllers/api_controller.go` `Login`; `controllers/login_gates.go`; `classes/login_gate.go` | `TestLoginGates` (order, first result or error wins, nil registry, concurrent Add and Check under `-race`), `TestLoginGateOrder` (refused first factors never reach a gate, answers pass through, no event, no last_seen), `TestLoginGateSmoke/gate_error` (U) | closed |
| T-19.6-02 | restore of a soft-deleted account before the second factor (Pitfall 21, D-18 A10) | The restore and its reactivate mail run after the gates; the gate sees the account still deleted. | U `Login`; M `classes/login.go` `CompleteLoginAs` (FinishLogin after the factor) | `TestLoginGateOrder/challenged_soft_deleted_stays_deleted`, `allowed_soft_deleted_restored_once` (U); `TestGate/deactivated_user_is_challenged_and_stays_deactivated` (M); `TestSocialGate/challenge_status_200` (S) | closed |
| T-19.6-03 | trusted devices surviving a password change (D-07) | Reset, own change and admin set fire `AccessRevokedEvent` (`PasswordChanged()` true) inside the write's transaction; M deletes the user's trusted devices on `ev.Tx`; a listener error rolls the write back; the rehash and a ban fire no password change. | U `controllers/api_controller.go` `ResetPassword`, `ChangePassword`; `controllers/users_admin_controller.go` `FormBeforeUpdate`; M `classes/admin.go` `OnAccessRevoked` | `TestPasswordChangedSources` (each source once, in the transaction, rollback, rehash and registration fire none) (U); `TestRevocation` (all three sources delete devices, rehash and ban keep them) (M) | closed |
| T-19.6-04 | Reset 2FA record action (D-15) | Permission `golem15.users.reset_two_factor` (no default role), danger confirmation, offered only with a published `TwoFactorAdmin` that reports a factor, `Run` only in cabana's transaction, Reset's error rolls back. | U `controllers/users_admin_controller.go` `resetTwoFactorAction`; M `classes/admin.go` `Admin` | `TestResetTwoFactorAction` (U); `TestAdminReset` (end to end through the Users screen, rollback on a failing listener) (M) | closed |
| T-19.6-05 | short-code guessing to bind a victim's pending device | 31^8 codes from crypto/rand without modulo bias, 5-minute TTL, user-api bucket, guarded pending-to-confirmed UPDATE requiring `user_id` NULL or the caller. | U `classes/device_auth.go` `NewDeviceShortCode`; `controllers/devices.go` `DeviceAuthorize` | `TestDeviceCodes` (distribution over 10,000 draws), `TestDeviceAuthorize` (bound to another user, concurrent authorizes by two users bind one) (U) | closed (mutation-checked) |
| T-19.6-06 | device-code phishing | D-12c keeps PHP's rule (any signed-in session may authorize); the device list shows name and address, revoke signs the device out at once. | U `controllers/devices.go` | `TestDeviceListAndRevoke` (U) | accepted (D-12c): the frontend must show device name and address before confirming |
| T-19.6-07 | device hand-over race | One guarded confirmed-to-used UPDATE; a poll that loses drops its minted token unsent. | U `controllers/devices.go` `handOverDevice` | `TestDeviceHandOverConcurrent` (50 polls, one token, one login event), `TestDeviceFlowSmoke` (U) | closed (mutation-checked) |
| T-19.6-08 | revoked device keeps refreshing (RESEARCH Q7) | Refresh hands the row's `jwt_jti` to the new token; revoke blacklists the current jti for the whole refresh window. | U `controllers/api_controller.go` `Refresh`; `classes/device_auth.go` `HandOverDeviceJTI`; `controllers/devices.go` `DeviceDestroy` | `TestDeviceRevokeAfterRefresh` (one and two refreshes, the lineage signed out, the blacklist entry outlives the window, a failed refresh leaves the row) (U) | closed; the `blacklist_grace` fork is a residual risk (below) |
| T-19.6-09 | device tokens at rest and in URLs | Only the SHA-256 hex is stored; the session JWT is never stored (only its jti); the framework access log records no path. | U `controllers/devices.go`; F `modules/surf/accesslog.go` | `TestDeviceTokensAtRest`, `TestDeviceStatus/unknown` (the hash is no token) (U) | closed; reverse-proxy logging of `devices/status/{token}` is a residual risk (README) |
| T-19.6-10 | unauthenticated initiate filling the table (Pitfall 16) | user-api bucket and a cleanup of at most 500 stale pending or expired sessions per initiate. | U `controllers/devices.go` `DeviceInitiate` | `TestDeviceInitiate/cleanup_is_bounded` (U) | closed |
| T-19.6-11 | attacker-chosen `device_name` (Pitfall 14) | At most 255 characters (runes) with PHP's 422; rendered as text by clients. | U `DeviceInitiate` | `TestDeviceInitiate/device_name` (U) | closed |
| T-19.6-12 | core plugin contract (19.4 D-05) | Additive exports, routes, config keys and one added migration. | U commits `8501f34`..`3b22134` | gate `--userplugin` (no exported declaration removed or changed since `50da92c`, only `202610260001_create_device_auth_sessions` added under `updates/`), `--fonoteka` (F) | closed |
| T-19.6-SC (plan 01) | Go module installs | None in U; approved pins for M. | F `.planning/notes/2026-10-21-mfa-otp-webauthn.md` | gate `--docs` (note and STACK rows), `--fonoteka` (no second-factor module in the workspace graph) (F) | closed |
| T-19.6-13 | TOTP and email brute force across challenges and addresses (Pitfall 1) | 2fa-verify bucket (10 a minute per client address), five attempts a challenge, the per-user failure cap counted from challenge rows (10 in 15 minutes, D-18 A6). | M `buckets.go`; `classes/challenge.go` `FailureCapReached`; `controllers/challenge.go` `attempt` | `TestFailureCap` (formula, window, in-flight attempt, refusal at login and on every verify route even with a correct factor, configured cap), `TestChallengeLifecycle/five_attempts_across_routes`, `TestRoutesAndBuckets` (11th request 429, keyed per client) (M) | closed |
| T-19.6-14 | parallel verifies (attempt bypass, double mint) | Guarded attempt increment and one guarded completion UPDATE in the transaction that consumes the factor. | M `classes/challenge.go` `BeginAttempt`, `CompleteChallenge` | `TestChallengeLifecycle/one_completion_when_factors_race` (20 TOTP and recovery verifications, one token, at most one code spent), `TestChallengeSmoke/parallel_verify`, `TestRecovery/single_use_under_concurrency` (M) | closed (mutation-checked) |
| T-19.6-15 | TOTP replay inside the window (Pitfall 5) | Last accepted step in metadata, accepted by a guarded UPDATE; a step at or below it is refused. | M `classes/totp.go` `VerifyTOTP`; `classes/methods.go` `AcceptTOTPCounter` | `TestTOTP/replay`, `TestTracerSmoke` (M) | closed |
| T-19.6-16 | email downgrade of a TOTP-only user (Pitfall 10, A8) | `email/send` and `verify method=email` only while the email method is enabled; otherwise the generic 401. | M `classes/email.go` `SendEmailCode`; `controllers/challenge.go` `verifyEmail` | `TestEmailCode/gating`, `TestPHPErrorBodies` (M) | closed |
| T-19.6-17 | mail bombing through email/send | At most 3 sends a challenge in one guarded UPDATE, the bucket and the failure cap. | M `SendEmailCode` | `TestEmailCode/sends_replace_and_cap`, `TestFailureCap/refused_everywhere_at_the_cap` (M) | closed (mutation-checked) |
| T-19.6-18 | secrets at rest (D-06) | SHA-256 hex for challenge and trusted tokens, `lagoon.Encrypted` TOTP secret, bcrypt recovery and email codes; models hide the columns from JSON. | M `classes/challenge.go`, `classes/trusted.go`, `classes/methods.go` `EnableMethod`, `classes/recovery.go`, `classes/email.go` | `TestTokensAtRest` (M) | closed |
| T-19.6-19 | logs and error strings | No code, token, secret or password in a log line; 500 answers opaque. | M, U (section "Log calls") | `TestEmailCode/never_logged` (no mailed code or challenge token in any app or default log line, a failing listener forcing log lines) (M) | closed |
| T-19.6-20 | stolen trusted-device token | SHA-256 at rest, bound to the user, 30-day expiry, off by default, revoked by any password change, the last method's removal and the revoke routes; a social sign-in is never skipped (A7). | M `classes/trusted.go`, `classes/gate.go` | `TestTrustedDevice` (foreign, unknown, malformed, expired tokens and the setting off are challenged; social never skipped), `TestRevocation` (M) | closed |
| T-19.6-21 | management routes touching another user's rows | Every query scoped by the JWT user; `{id}` routes constrained to digits; 404 for foreign ids. | M `routes.go`, `controllers/management.go`, `controllers/webauthn.go` | `TestRoutesAndBuckets` (jwt.auth on every management route), `TestWebAuthn/delete_scoped_to_the_owner` (foreign 404, the router's 404 for non-numeric ids), `TestTrustedSettingsSmoke/revoke_routes` (M) | closed |
| T-19.6-22 | recovery codes after creation | Shown once (first set D-19, regenerate); the count route needs the password and shows a count only. | M `controllers/management.go` | `TestRecovery/first_set_only_when_created`, `count_route`, `regenerate` (M) | closed |
| T-19.6-23 | bcrypt CPU per wrong recovery code (Pitfall 9) | Input normalized and capped at 32 characters, first match wins, the bucket and the cap. | M `classes/recovery.go` `NormalizeRecoveryCode` | `TestRecoveryCodeFormat`, `TestRecovery/normalization_through_the_route` (M) | closed |
| T-19.6-24 | settings values (USER-005 type confusion, unknown methods or groups) | Typed mode with an exact allow-list in `Rules`, method and group allow-lists in `BeforeValidate`, a stored unknown mode behaves as optional, an empty list falls back to the configuration (`71d8f27`). | M `models/settings.go`, `classes/config.go` | `TestSettings` (overlay per key, rules, allow-lists, form round trip), `TestPHPUser005` (M) | fixed in commit 71d8f27 (with its test) |
| T-19.6-25 | client-chosen TOTP secret at confirm | Only for the caller's own account; base32 and 16-64 characters validated; a current code from the secret is required. | M `controllers/management.go` `TOTPConfirm` | `TestPHPErrorBodies` (format and code refusals) (M) | accepted (PHP parity) |
| T-19.6-SC (plan 02) | Go module installs | pquerna/otp v1.5.0 and boombuler/barcode v1.1.0 after the recorded approval; no archived YAML module. | M `go.mod` | gate `--mfa` (pins, `go list -deps` free of `gopkg.in/yaml.v3`) (F) | closed |
| T-19.6-26 | WebAuthn challenge supplied by the client (Pitfall 6) | Registration and passkey state server-side in cooler, consumed with `Pull` and scoped to the user; the login challenge on the challenge row; the client's challenge only a lookup key. | M `classes/webauthn.go` `BeginRegistration`, `FinishRegistration`, `BeginAssertion`, `FinishAssertion`; `classes/passwordless.go` | `TestWebAuthnShapes/registration_state` (used once, another user's state refused), `TestWebAuthn/registration`, `assertion_options` (M) | closed |
| T-19.6-27 | passkey login with a second-factor credential (D-14) | `passwordless` set only for a requested, user-verified, credProps-rk credential; `ValidatePasskeyLogin` with UV required; the handler checks enabled, passwordless and the user handle. | M `classes/webauthn.go` `FinishRegistration`; `classes/passwordless.go` | `TestPasswordless`, `TestWebAuthnShapes/passkey_flag` (M); section "D-14" | closed (mutation-checked) |
| T-19.6-28 | cloned authenticator | The record written back after every assertion (sign count, flags) with a guarded UPDATE; a clone warning and a backup-eligibility change refuse. | M `classes/webauthn.go` `VerifyAssertion`, `RecordAssertion` | `TestWebAuthn/assertions` (clone and BE change refused, the stored record unchanged), `TestWebAuthnCeremonySmoke` (M) | closed |
| T-19.6-29 | RP ID or origin misconfiguration (Pitfall 7) | Explicit `webauthn.rp_id` and `origins` with app.url and frontend_url defaults; an IP host or no origin makes WebAuthn unavailable; the library checks origin and the RP ID hash. | M `classes/config.go` `loadWebAuthnConfig`; `classes/webauthn.go` `webAuthnFromConfig` | `TestWebAuthnShapes` (IP host, IP RP ID, no origin), `TestWebAuthn` (foreign origin at registration and assertion), `TestPasswordless/refused_foreign_origin` (M) | closed |
| T-19.6-30 | social sign-in bypassing an enabled second factor (D-09) | sociallogin's `signIn` asks the gates with source social before FinishLogin and any token; a challenge replaces the token in both completions; refusals and errors fail closed; a link stays ungated. | S `classes/flow.go` `signIn`; M `classes/gate.go` | `TestSocialGate` (existing identity, verified email match, new account; no cookie, no SignedInEvent; refusals; error; link) (S); `TestEveryLoginPathIsGated/social_sign_in` (M) | closed (mutation-checked on M's gate) |
| T-19.6-31 | passkey login of a banned, suspended or inactive account | Activation, `ThrottleStates` (anywhere) and `RejectIfThrottled` (this address) before any token; one generic body. | M `classes/passwordless.go` `FinishPasswordless` | `TestPasswordless/refused_banned`, `refused_suspended`, `refused_not_activated` (M) | closed |
| T-19.6-32 | remote operations of plan 03 | Blocking decision first, fetch and range check before every push, no force, no deploy, fonoteka.go never pushed. | 19.6-03-SUMMARY.md "Remote operations" | process control | accepted (process; recorded in plan 03) |
| T-19.6-33 | demo rendering the server's QR SVG | Rendered through an image data URL, never `v-html`; the helper escapes markup. | D `vue-demo-app/app/lib/twoFactor.ts` `svgDataUrl` | `twoFactor.test.ts` (`svgDataUrl` keeps no `<`), gate `--demo-web` (D) | closed |
| T-19.6-34 | WebAuthn user handle from the numeric user id (A4) | PHP-compatible 8-byte big-endian handle so imported passkeys keep mapping. | M `classes/webauthn.go` `UserHandle` | `TestWebAuthnShapes/user_handle` (M) | accepted: it reveals only an internal id to the user's own authenticator |
| T-19.6-35 | ceremony state in the memory cooler on several instances (A11) | Documented: multi-instance applications use the Redis cooler; a ceremony that reaches another instance fails closed. | M README | `TestPasswordless/refused_challenges_and_bodies` (unknown state refused) (M) | accepted (README) |
| T-19.6-SC (plan 03) | Go module installs | go-webauthn v0.18.2 after the recorded approval; fonoteka.go's graph has no go-webauthn. | M `go.mod`; A `go.mod` | gate `--mfa`, `--fonoteka` (F) | closed |
| T-19.6-36 | the gate's verdict | The `go test -json` detector fails on fail, skip, zero matches, "no tests to run" and missing names; a subtest pass never counts for its parent; the vitest detector needs the named file; docker NOT RUN explicit and fatal with `PHASE196_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.6.sh` | `scripts/check-phase19.6.sh --self-test` (planted output for every detector, the additive, pin and migration checks, the docker rule on a faked PATH) (F) | closed |
| T-19.6-37 | post-release fixes not reaching the applications | Task 4's table lists every production fix against every pin; Task 6 releases in D-17 order under the Task 5 reply and checks each pin with `merge-base --is-ancestor`; hold writes a todo. | 19.6-04-SUMMARY.md "Post-release fixes" | Task 6: `merge-base --is-ancestor 71d8f27` exits 0 for tag v0.1.1, the sm-demo-app gitlink and go.mod require, and the sm-bm-app gitlink | closed (released as v0.1.1; sm-bm-app's push waits on another session's unpushed commits, see 19.6-04-SUMMARY.md) |
| T-19.6-38 | a token mint path that skips the second factor (assumption-delta debt) | Every mint site of U and M listed with its reason and checked against the source; every first-factor path of a TOTP user driven to a challenge; passkey and device poll mint only through their own proof. | M `login_paths_test.go` | `TestEveryLoginPathIsGated` (M) | closed for the listed paths; activate-by-code is accepted debt with the corrected reason of finding F-1 (deferred, below) |
| T-19.6-SC (plan 04) | Go module installs | Plan 04 installs no module: no go.mod of U, M or S changed since the releases. | U, M, S `go.mod` | gate `--mfa` pins; `git diff` of the go.mod files since the release tags is empty | closed |
## D-14: passwordless passkey login
Passkey sign-in issues a session token without a password and without a second challenge, so each rule below is pinned by a subtest that fails when the rule goes.
| Rule | As built | Pinned by |
|------|----------|-----------|
| Setting default | `two_factor_passwordless_login` defaults to false (config and settings); both routes answer 403 `Passwordless login is not available.` while it is off, and also while `two_factor_mode` is disabled (fail closed). | `TestSettings/config_defaults_and_validation`, `TestPasswordless/off_or_mode_disabled`, `TestPasswordlessSmoke/setting_off` |
| Credential eligibility | Only a credential registered as a passkey qualifies: `passwordless` is set when the registration asked for one (and only while the setting is on), the authenticator verified the user and the browser's credProps reported a resident key. A second-factor key, or a requested passkey without credProps, stays a second factor. | `TestWebAuthnShapes/passkey_flag`, `TestPasswordless/refused_second_factor_key`, `TestPasswordlessSmoke/second_factor_key_refused` |
| User verification at registration | Passkey creation options require a resident key and user verification and ask for credProps; a registration without UV is refused. | `TestWebAuthnShapes/passkey_creation_options`, `passkey_flag/requested_without_uv` |
| User verification at login | The request options require UV and allow no credential list (discoverable); `ValidatePasskeyLogin` runs with UV required. | `TestWebAuthnShapes/passkey_request_options`, `TestPasswordless/refused_uv_missing` |
| User-handle binding | The assertion's user handle must be the credential owner's 8-byte handle; another user's handle or none is refused. | `TestPasswordless/refused_other_users_handle`, `refused_no_handle` |
| Enabled and ownership checks | The credential row must exist, be enabled and be a passkey; a disabled or removed credential is refused. | `TestPasswordless/refused_disabled_credential`, `refused_removed_credential` |
| Activation, ban and suspension | The owner must be activated and neither banned nor suspended, from any address (`ThrottleStates`) or this one (`RejectIfThrottled`). | `TestPasswordless/refused_not_activated`, `refused_banned`, `refused_suspended` |
| Generic failures | Every refusal answers the same 401 `{"error":"WebAuthn verification failed."}`; no reason leaks. | every `TestPasswordless/refused_*` subtest compares the body byte for byte |
| Single-use state | The passkey ceremony state is pulled from cooler once; a reused, unknown or over-long challenge, and a state spent by an unparsable attempt, are refused. Multi-instance caveat: with the memory cooler a ceremony must stay on one instance (README, T-19.6-35). | `TestPasswordless/refused_challenges_and_bodies`, `TestPasswordlessSmoke/state_once` |
| Origin and RP ID | go-webauthn checks the client origin against `webauthn.origins` and the RP ID hash against `webauthn.rp_id`. | `TestPasswordless/refused_foreign_origin`, `TestWebAuthnShapes` |
| Clone and backup-eligibility refusal | A clone warning refuses the sign-in and logs the credential row id; a changed BE flag is refused by the library (pinned on the second-factor path, which shares `ValidateLogin`'s flag checks). | `TestPasswordless/signs_in_without_a_second_challenge` (clone), `TestWebAuthn/assertions` (BE) |
| Rate limiting | Both passkey routes sit in the 2fa-verify bucket (10 a minute per client address). | `TestRoutesAndBuckets` |
| Deactivated-account restore | A deactivated owner is restored through golem15.user's `FinishLogin` with one reactivate mail: the passkey is both factors, so the restore follows a full sign-in (D-18 A10 holds). | `TestPasswordless/deactivated_owner_restored_and_mailed` |
| Logging | Only the clone warning logs (credential row id and user id); no assertion, challenge or token. | section "Log calls" |
| No second challenge | A passkey with user verification is both factors (D-14): a user with TOTP gets a token, no challenge row is created, the login event's source is `passkey`, and the gate never challenges source passkey. | `TestPasswordless/signs_in_without_a_second_challenge`, `TestGate/sources`, `TestEveryLoginPathIsGated/passkey_only_with_its_assertion` |
## D-18 hardening
| Item | As built | Pinned by |
|------|----------|-----------|
| A6 per-user failure cap | 10 failures in 15 minutes (configurable) from challenge rows, across instances and addresses; refuses new challenges and every verify route with their generic answers; the caller's in-flight attempt is not a failure. | `TestFailureCap` |
| A8 email only when enabled, 3 sends | `email/send` and email verification need the enabled email method; three sends a challenge, each replacing the code. | `TestEmailCode/gating`, `sends_replace_and_cap` |
| A9 promised lifetime | The mail promises min(email code TTL, the challenge's remaining minutes), rounded up, at least 1; the code dies with the challenge. | `TestEmailExpiryMinutes`, `TestEmailCode/expiry_capped_by_the_challenge`, `code_dies_with_the_challenge` |
| A10 restore after the gate | Password, social and passkey sign-ins restore a deactivated account only after the second factor (or the passkey). | `TestLoginGateOrder`, `TestGate/deactivated_user_is_challenged_and_stays_deactivated`, `TestSocialGate/challenge_status_200`, `TestPasswordless/deactivated_owner_restored_and_mailed` |
## ASVS L1 notes (RESEARCH Security Domain)
- **V2 Authentication.** TOTP with a one-step window and per-step replay refusal (`TestTOTP`); WebAuthn with server-side state, UV rules and clone and BE refusal (`TestWebAuthn`, `TestPasswordless`); bcrypt recovery and email codes (`TestTokensAtRest`); the per-IP bucket and the per-user cap (`TestRoutesAndBuckets`, `TestFailureCap`); guarded attempt counter (`TestChallengeLifecycle`).
- **V3 Session management.** Challenge tokens 64 characters, SHA-256 at rest, five minutes, one completion; the device JWT handed over once, followed through refresh and blacklisted on revoke (`TestDeviceHandOverConcurrent`, `TestDeviceRevokeAfterRefresh`); `tokens_valid_after` on password change (unchanged).
- **V4 Access control.** jwt.auth on every management route, every row query scoped by user, digit-constrained ids (`TestRoutesAndBuckets`, `TestWebAuthn/delete_scoped_to_the_owner`); the admin reset behind its own permission (`TestAdminReset`, `TestResetTwoFactorAction`).
- **V5 Validation.** Laravel's rules with Winter's English messages and PHP's 422 shapes (`TestPHPErrorBodies`); base32 and length checks on the TOTP secret; length caps on `device_name`, recovery input and the WebAuthn challenge echo.
- **V6 Cryptography.** Libraries only (pquerna/otp, go-webauthn, boombuler/barcode for the matrix); `crypto/rand` for tokens and codes (`TestDeviceCodes`, `TestRecoveryCodeFormat`); `lagoon.Encrypted` for the TOTP secret.
- **V7 Errors and logging.** Generic 401 bodies; no code, token, secret or assertion in a log line (`TestEmailCode/never_logged`, section "Log calls"); `Registration failed:` with fixed reasons.
## Log calls
Every non-test log call of M and of U's device and gate controllers, with what it carries:
| Where | Message | Attributes |
|-------|---------|------------|
| M `controllers/helpers.go` `serverError` | `golem15.mfa: <what>` | `err` (a database or library error; handlers pass no token, code or secret into it) |
| M `controllers/challenge.go` | verified and failed event listener | `user_id`, the listener's `err` |
| M `classes/gate.go` | challenged event listener | `user_id`, `err` |
| M `classes/login.go` | login event listener | `user_id`, `err` |
| M `classes/webauthn.go` | credential without a record; record does not decode; clone warning | `credential_row`, `user_id` |
| M `classes/passwordless.go` | clone warning, passkey sign-in refused | `credential_row`, `user_id` |
| U `controllers/devices.go` | session cleanup; store device session; finish device login; login event listener; revoke device session | `err`, `user_id` |
| U `controllers/api_controller.go` `Login` | login gate | `user_id`, `err` |
None carries a token, code, secret, password, assertion or the device token. `TestEmailCode/never_logged` checks the mailed codes and the challenge token against every app and default log line of a run that forces the failure paths to log.
## Residual risks
- **Device-code phishing (D-12c):** an attacker can show a victim a QR or short code; the frontend must show the device name and address before confirming. Accepted by decision.
- **`blacklist_grace` forks (Pitfall 15):** with a grace above 0, two concurrent refreshes of one device token both succeed and only one new jti is followed, so a revoke misses the other fork until it expires; the default grace is 0 and a password change ends both.
- **Memory cooler (A11):** WebAuthn ceremonies need one instance or the Redis cooler (README).
- **Numeric user handle (A4):** the handle reveals the internal user id to the user's own authenticator.
- **Reverse proxies** may log `devices/status/{token}`; the token lives five minutes and is single use.
- **JSON key order:** multi-key success bodies (the login challenge, `status`, `totp/setup`, `email/send`, the register answers) carry PHP's keys in Go's sorted order, not PHP's insertion order. JSON clients do not depend on key order, and the error bodies (single key or alphabetical in PHP too) and the 422 bodies (ordered explicitly) are byte-identical; recorded, not changed.
- **Route constraint ordering (framework):** surf checks a `{id}` digit constraint inside the route's middleware, so an unauthenticated `DELETE …/trusted-devices/abc` answers jwt.auth's 401 where Laravel answered 404; with a session it is the router's 404. No information leaks either way.
## Deferred findings
### F-1: activate-by-code mints a session without the second factor when `require_activation` is off
**Status: deferred** by the user at plan 04 Task 5 (2026-10-26); tracked in `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md`. **Corrected reason for the accepted mint path:** activate-by-code skips the login gates; with the default `require_activation: true` a not-activated account cannot sign in, so it cannot have a second factor and nothing is skipped. With `require_activation: false` a not-activated account can enable one, and then possession of the mailed activation code stands in for the second factor once, at activation.
- **What:** plan 01's assumption-delta decision accepts `POST /_user/api/v1/activate-by-code` as an ungated mint because "a new or not-yet-activated account cannot have 2FA yet". That holds with the default `golem15.user.activation.require_activation: true` (a not-activated account cannot sign in). With `require_activation: false` and `activate_mode: user`, a not-activated account signs in with its password, can enable TOTP, and a later `activate-by-code` with the mailed activation code answers `{"message":"Account activated","token":…}` without a challenge.
- **Reproduced:** on this host through the mfa test harness (register in mode user, password login, TOTP setup and confirm, password login now challenged, `IssueActivationCode`, activate-by-code answers 200 with a token).
- **Impact:** low. It needs a non-default configuration, a user who enabled a second factor before activating, and an attacker holding that user's unexpired activation code (their mailbox). It lets the mailbox alone stand in for the second factor, which a password reset does not (a reset mints no token).
- **Options (raised at plan 04 Task 5; the user chose (c)):**
(a) fix in sm-user-plugin: after the code verifies, `ActivateByCode` asks the login gates (for example with source password, so sm-mfa-plugin v0.1.0's gate challenges it) and answers the gate's body instead of the token; the account is still activated. A patch release of sm-user-plugin and pointer bumps in sm-demo-app, sm-bm-app and fonoteka.go.
(b) accept with the corrected reason ("only with `require_activation: false`; the mailed code stands in for the second factor") recorded in plan 01's decision and the README.
(c) defer to a todo for a later phase.
- **Pinned now:** `TestEveryLoginPathIsGated/mint_sites` lists `ActivateByCode` with a pointer to this finding, so any change to the mint sites fails until the list is updated.
## Sign-off
Every T-19.6 threat of plans 01-04 is closed by a passing test or accepted with its reason; F-1 (under T-19.6-38) is deferred by the user with its corrected reason. `scripts/check-phase19.6.sh --all` passed with Docker (all 11 stages); see 19.6-04-SUMMARY.md.

View File

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

View File

@@ -0,0 +1,40 @@
---
status: testing
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
source: [19.6-VERIFICATION.md]
started: 2026-10-26T16:03:15.784Z
updated: 2026-10-26T16:03:15.784Z
---
## Current Test
number: 1
name: Run a real-browser WebAuthn ceremony against an instance with webauthn in two_factor_available_methods, served over HTTPS with a hostname RP ID: register a security key (POST 2fa/webauthn/register/options, navigator.credentials.create, POST 2fa/webauthn/register), sign out, sign in with the password and complete the challenge through 2fa/webauthn/options and 2fa/webauthn/verify. With two_factor_passwordless_login on, register a passkey (passwordless true) and sign in through 2fa/webauthn/passwordless/options and /verify.
expected: |
Registration answers {message, credential:{id,name,created_at}, status} (plus recovery_codes on the first set); the challenge completes with {token, user}; the passkey sign-in issues a token with no second challenge; a second-factor-only key is refused for passwordless with 401 WebAuthn verification failed.
awaiting: user response
## Tests
### 1. Run a real-browser WebAuthn ceremony against an instance with webauthn in two_factor_available_methods, served over HTTPS with a hostname RP ID: register a security key (POST 2fa/webauthn/register/options, navigator.credentials.create, POST 2fa/webauthn/register), sign out, sign in with the password and complete the challenge through 2fa/webauthn/options and 2fa/webauthn/verify. With two_factor_passwordless_login on, register a passkey (passwordless true) and sign in through 2fa/webauthn/passwordless/options and /verify.
expected: Registration answers {message, credential:{id,name,created_at}, status} (plus recovery_codes on the first set); the challenge completes with {token, user}; the passkey sign-in issues a token with no second challenge; a second-factor-only key is refused for passwordless with 401 WebAuthn verification failed.
result: [pending]
### 2. On the demo (vue-demo-app served by sm-demo-app at the v0.1.13 / v0.1.2 / v0.1.5 pins): sign in, open /account/security, enable TOTP by scanning the QR code with a real authenticator app and entering a code, keep the recovery codes shown once, sign out, sign in again and answer the challenge with an app code; repeat with a recovery code; then disable TOTP.
expected: The QR renders as an image and scans; the recovery codes appear once after confirm; the login page switches to the code step on two_factor_required and stores the token after verify or recovery; a used recovery code is refused the second time; after disable the login issues a token directly.
result: [pending]
### 3. Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts, 19 items across plans 01-04).
expected: Confirm or dispute each verdict; all are recorded as no violation found.
result: [pending]
## Summary
total: 3
passed: 0
issues: 0
pending: 3
skipped: 0
blocked: 0
## Gaps

View File

@@ -3,10 +3,11 @@ phase: "19.6"
slug: "two-factor-auth-and-device-flow-in-sm-user-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
status: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-10-21"
validated: "2026-10-26"
---
# Phase 19.6 — Validation Strategy
@@ -19,30 +20,49 @@ created: "2026-10-21"
| Property | Value |
|----------|-------|
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (DB tests skip under `-short`) |
| **Config file** | none — Wave 0 adds `sm-mfa-plugin/testmain_test.go` and `harness_test.go` (copied from `sm-sociallogin-plugin`) |
| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (DB tests skip under `-short`); vitest for `vue-demo-app` |
| **Config file** | none: `sm-mfa-plugin/testmain_test.go` and `harness_test.go` (Wave 0, plan 02), sm-user-plugin's `session_test.go` and `admin_harness_test.go` |
| **Quick run command** | `go -C ../sm-mfa-plugin test -short ./...` and `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` |
| **Full suite command** | `scripts/check-phase19.6.sh` (go, userplugin, mfa, sociallogin, demo, bm, fonoteka, docker, docs stages) |
| **Estimated runtime** | ~180 seconds (full, with Docker) |
| **Full suite command** | `scripts/check-phase19.6.sh --all` (self-test, go, userplugin, mfa, sociallogin, demo, demo-web, bm, fonoteka, docs, then docker) |
| **Estimated runtime** | about 6 minutes for `--all` with Docker on this host |
---
## Sampling Rate
- **After every task commit:** `go vet ./...` + `go test -short ./...` of the touched repo
- **After every plan wave:** full suites (Docker) of every touched repo
- **Before `/msd-verify-work`:** `scripts/check-phase19.6.sh` must be green
- **After every task commit:** `go vet ./...` + `go test -short ./...` of the touched repository
- **After every plan wave:** full suites (Docker) of every touched repository
- **Before `/msd-verify-work`:** `scripts/check-phase19.6.sh --all` must be green
- **Max feedback latency:** 60 seconds for the short loop
---
## Per-Task Verification Map
Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the planner assigns task IDs.
Gate stages are `scripts/check-phase19.6.sh --<stage>`; a test named under a stage is required by name there (a skip or a missing name fails the stage). U = sm-user-plugin, M = sm-mfa-plugin, S = sm-sociallogin-plugin, D = sm-demo-app, B = sm-bm-app, A = the fonoteka.go workspace.
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command (gate stage: tests) | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 19.6-XX-XX | TBD | TBD | D-XX | T-19.6-XX | per RESEARCH map | unit/integration | per RESEARCH map | ❌ W0 | ⬜ pending |
| 19.6-01-01 | 01 | 1 | D-02, D-04, D-18 A10 | T-19.6-01, T-19.6-02 | Password login asks the gates before restore and mint; a gate error fails closed; the library note names the pins | unit + integration | `check-phase19.6.sh --userplugin`: TestLoginGatesSmoke, TestLoginGates (also `-race`); `--docker`: TestLoginGateSmoke, TestLoginGateOrder (U); `--docs`: the note and STACK rows | ✅ | ✅ green |
| 19.6-01-02 | 01 | 1 | D-07, D-15 | T-19.6-03, T-19.6-04 | Password writes fire the revocation event in their transaction; Reset 2FA only with a service and the permission | integration | `check-phase19.6.sh --docker`: TestPasswordChangedEventSmoke, TestPasswordChangedSources, TestResetTwoFactorSlotSmoke, TestResetTwoFactorAction (U) | ✅ | ✅ green |
| 19.6-01-03 | 01 | 1 | D-11, D-12a, D-12c, D-06, D-16 | T-19.6-05, T-19.6-07, T-19.6-09, T-19.6-10, T-19.6-11 | Device initiate, status and authorize with hashed tokens, one-time hand-over, bounded cleanup | unit + integration | `--userplugin`: TestDeviceCodesSmoke, TestDeviceCodes, TestDeviceAuthURL, TestDeviceDisplayName; `--docker`: TestDeviceFlowSmoke, TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize, TestDeviceHandOverConcurrent, TestDeviceTokensAtRest, TestDeviceAuthSessionsMigration (U) | ✅ | ✅ green |
| 19.6-01-04 | 01 | 1 | D-12b | T-19.6-08 | Device list and revoke; the jti follows refresh | integration | `--docker`: TestDeviceRevokeSmoke, TestDeviceListAndRevoke, TestDeviceRevokeAfterRefresh (U) | ✅ | ✅ green |
| 19.6-01-05 | 01 | 1 | D-04 | T-19.6-SC | Human package check before any install | manual (checkpoint) | reply "approved" (19.6-01-SUMMARY.md); `--mfa` pins, `--fonoteka` module check | ✅ | ✅ green |
| 19.6-02-01 | 02 | 2 | SC-2, D-04, D-05, D-06 | T-19.6-13, T-19.6-15, T-19.6-18 | TOTP with QR SVG, challenge instead of a token, replay refused, 2fa-verify bucket | unit + integration | `--mfa`: TestQRSVGSmoke, TestTOTP, TestQRSVG, TestRoutesAndBuckets; `--docker`: TestTracerSmoke, TestGate, TestChallengeLifecycle, TestTokensAtRest, TestPHPErrorBodies (M) | ✅ | ✅ green |
| 19.6-02-02 | 02 | 2 | D-05, D-18 A6, A8, A9, D-19 | T-19.6-13, T-19.6-14, T-19.6-16, T-19.6-17, T-19.6-22, T-19.6-23 | Recovery codes once, email codes only when enabled with a send cap and a capped lifetime, the per-user failure cap | unit + integration | `--mfa`: TestRecoveryCodeFormat, TestMaskEmail, TestEmailExpiryMinutes; `--docker`: TestChallengeSmoke, TestRecovery, TestEmailCode, TestFailureCap (M) | ✅ | ✅ green |
| 19.6-02-03 | 02 | 2 | D-07, D-10, D-12, D-13, D-15, D-16 | T-19.6-03, T-19.6-20, T-19.6-21, T-19.6-24 | Trusted devices revoked on every password change and the last method; settings per request with allow-lists; admin reset | integration | `--docker`: TestTrustedSettingsSmoke, TestTrustedDevice, TestRevocation, TestSettings, TestStatusShape, TestAdminReset, TestMigrations (M); `--mfa`: TestPHPMethodMap, TestPHPUser005 | ✅ | ✅ green |
| 19.6-03-01 | 03 | 3 | SC-1, D-08, D-16 | T-19.6-26, T-19.6-28, T-19.6-29 | WebAuthn second factor with server-side state, clone and BE refusal, origin and RP checks | unit + integration | `--mfa`: TestWebAuthnCeremonySmoke, TestWebAuthnShapes; `--docker`: TestWebAuthnSmoke, TestWebAuthn (M) | ✅ | ✅ green |
| 19.6-03-02 | 03 | 3 | D-14, D-09 | T-19.6-27, T-19.6-30, T-19.6-31 | Passkeys only for eligible credentials and allowed owners; social sign-in through the gate | integration | `--docker`: TestPasswordlessSmoke, TestPasswordless, TestEveryLoginPathIsGated (M); TestSocialGateSmoke, TestSocialGate (S) | ✅ | ✅ green |
| 19.6-03-03 | 03 | 3 | D-20 | T-19.6-33 | Demo TOTP setup and challenge; the QR rendered as an image data URL | unit | `check-phase19.6.sh --demo-web`: `app/lib/twoFactor.test.ts` (D) | ✅ | ✅ green |
| 19.6-03-04 | 03 | 3 | D-17 | T-19.6-32 | Go-ahead before any push, tag or repository creation | manual (checkpoint) | reply recorded in 19.6-03-SUMMARY.md | ✅ | ✅ green |
| 19.6-03-05 | 03 | 3 | D-17 | T-19.6-32 | Releases in D-17 order, no force-push | manual + gate | `--mfa` (go.mod pins), `--sociallogin`; ls-remote checks in 19.6-03-SUMMARY.md | ✅ | ✅ green |
| 19.6-03-06 | 03 | 3 | D-17, D-01 | T-19.6-12, T-19.6-SC | Demo and bm mount mfa; fonoteka.go builds at the new pointer without second-factor modules | integration | `--demo`: TestBootAllPlugins, TestGoModRequiresTags, TestMFAMounted; `--bm`: TestSocialLoginMounted, TestMFAMounted; `--fonoteka` (no golem15.mfa mount, no go-webauthn, pquerna or boombuler module) | ✅ | ✅ green |
| 19.6-04-01 | 04 | 4 | SC-4 | T-19.6-36 | The gate fails closed on a failed, skipped, missing or unrun test | unit (script) | `check-phase19.6.sh --self-test` | ✅ | ✅ green |
| 19.6-04-02 | 04 | 4 | SC-4, D-02, D-07, D-11, D-12a, D-12b, D-12c, D-15, D-16, D-18 | T-19.6-01..T-19.6-12 | Every user-plugin seam and device-flow state pinned by name | unit + integration | `--userplugin` and `--docker` (rows 19.6-01-01..04) | ✅ | ✅ green |
| 19.6-04-03 | 04 | 4 | SC-4, D-05..D-10, D-12..D-16, D-18, D-19 | T-19.6-13..T-19.6-31, T-19.6-38 | Every second-factor decision, every PHP body and the login-path invariant pinned by name | unit + integration | `--mfa`, `--sociallogin` and `--docker` (rows 19.6-02-01..19.6-03-02) | ✅ | ✅ green |
| 19.6-04-04 | 04 | 4 | SC-4, D-14 | all T-19.6 | PHP security test mapped; security review with the D-14 section; the whole gate green | unit + review | `check-phase19.6.sh --all`; `--mfa`: TestPHPMethodMap, TestPHPUser005; 19.6-SECURITY-REVIEW.md | ✅ | ✅ green |
| 19.6-04-05 | 04 | 4 | D-17 | T-19.6-37 | Go-ahead before releasing post-release fixes | manual (checkpoint) | reply recorded in 19.6-04-SUMMARY.md ("release"; F-1 deferred) | ✅ | ✅ green |
| 19.6-04-06 | 04 | 4 | D-17 | T-19.6-37 | Each post-release fix reaches every pin (`merge-base --is-ancestor`) or is a tracked todo | integration + manual | `check-phase19.6.sh --demo`, `--bm`, `--fonoteka` at the final pins; merge-base results in 19.6-04-SUMMARY.md | ✅ | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -50,10 +70,10 @@ Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the p
## Wave 0 Requirements
- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` — boot `golem15.user` + `golem15.mfa`
- [ ] WebAuthn fixtures — recorded attestation/assertion JSON or a virtual authenticator
- [ ] Clock injection for TOTP tests
- [ ] user-plugin harness helper for device-flow tests
- [x] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` — boot `golem15.user` + `golem15.mfa` (plan 02)
- [x] WebAuthn fixtures — a virtual authenticator (`webauthn_authenticator_test.go`, plan 03)
- [x] Clock injection for TOTP tests — `VerifyTOTP(secret, code, at, lastCounter)` with pquerna's `GenerateCodeCustom` at chosen times (`TestTOTP`)
- [x] user-plugin harness helper for device-flow tests — `session_test.go` and `admin_harness_test.go` helpers plus `device_flow_smoke_test.go`'s device helpers
---
@@ -62,16 +82,17 @@ Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the p
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| WebAuthn registration and login in a real browser | D-08 / SC-1 | Browser authenticator ceremony cannot run in `go test` | Register a passkey on the demo instance over HTTPS with a hostname RP ID, then sign in with it |
| Demo TOTP screens in a browser | D-20 | Look and flow of the Nuxt pages | On the deployed demo: enable TOTP from the QR, keep the recovery codes, sign out, answer the challenge with a code and with a recovery code |
---
## 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 < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 60s
- [x] `nyquist_compliant` set to true in the frontmatter
**Approval:** pending
**Approval:** validated 2026-10-26 (plan 19.6-04, `check-phase19.6.sh --all`; post-release fix released as sm-mfa-plugin v0.1.1).

View File

@@ -0,0 +1,312 @@
---
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
verified: 2026-10-26T16:05:13Z
status: human_needed
score: 45/45 must-haves verified
covered_files:
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-PLAN.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-PLAN.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-SUMMARY.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-PLAN.md
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-SUMMARY.md
- scripts/check-phase19.6.sh
covered_digest: "v2:sha256:96a0670daff17ba82f47a71e37a4bbbdb227e5dbe07d458f1a268f3389078072"
# Files outside this repository are pinned by commit instead of digest (all equal to the release tags on the remotes):
# sm-user-plugin 07196a4 = v0.1.13 (checkout fonoteka.go/plugins/golem15/user), sm-mfa-plugin 463c750 = v0.1.2,
# sm-sociallogin-plugin ecfb8a7 = v0.1.5, vue-demo-app 44a5229 (origin master), sm-demo-app 8fcdbdd (origin master;
# gitlinks and go.mod requires at the three tags), sm-bm-app HEAD c665073 (gitlinks at the three tags via the local,
# UNPUSHED bumps 56a79ab, 1c1db04, 604b06c; origin/master ebb2266 still pins user 3b22134, mfa 33ad164, sociallogin
# b752177), fonoteka.go e36577a (user gitlink 07196a4, unpushed by design).
behavior_unverified: 0
overrides_applied: 0
# Refreshed after bc8f954 (gate requires the six review-fix regression tests): --self-test and --docker re-run, digest recomputed.
# Human items below seed 19.6-UAT.md (created 2026-10-26T16:03:15Z, 3 pending, no results yet).
human_verification:
- test: "Run a real-browser WebAuthn ceremony against an instance with webauthn in two_factor_available_methods, served over HTTPS with a hostname RP ID: register a security key (POST 2fa/webauthn/register/options, navigator.credentials.create, POST 2fa/webauthn/register), sign out, sign in with the password and complete the challenge through 2fa/webauthn/options and 2fa/webauthn/verify. With two_factor_passwordless_login on, register a passkey (passwordless true) and sign in through 2fa/webauthn/passwordless/options and /verify."
expected: "Registration answers {message, credential:{id,name,created_at}, status} (plus recovery_codes on the first set); the challenge completes with {token, user}; the passkey sign-in issues a token with no second challenge; a second-factor-only key is refused for passwordless with 401 WebAuthn verification failed."
why_human: "Server-side ceremonies are proven with a software authenticator (real ECDSA P-256 and CBOR, TestWebAuthn, TestPasswordless), but no test runs a browser's navigator.credentials JSON (credProps, transports, base64url fields) against the D-08 shapes, or a real RP ID and origin. No client exists yet: the demo has no WebAuthn screens and its config/golem15.mfa.yaml leaves webauthn out of the available methods, so the 19.6-VALIDATION.md manual row ('on the demo instance') needs another client page."
- test: "On the demo (vue-demo-app served by sm-demo-app at the v0.1.13 / v0.1.2 / v0.1.5 pins): sign in, open /account/security, enable TOTP by scanning the QR code with a real authenticator app and entering a code, keep the recovery codes shown once, sign out, sign in again and answer the challenge with an app code; repeat with a recovery code; then disable TOTP."
expected: "The QR renders as an image and scans; the recovery codes appear once after confirm; the login page switches to the code step on two_factor_required and stores the token after verify or recovery; a used recovery code is refused the second time; after disable the login issues a token directly."
why_human: "User-facing flow and look (D-20). Covered only by twoFactor.test.ts helpers and the Go route tests; SUMMARY 03 and 04 record the browser run as NOT RUN."
- test: "Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts, 19 items across plans 01-04)."
expected: "Confirm or dispute each verdict; all are recorded as no violation found."
why_human: "Judgment-tier prohibitions never pass silently (ADR-550 D4)."
---
# Phase 19.6: Two-factor auth and device flow in sm-user-plugin Verification Report
**Phase Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization. Per CONTEXT D-01 the second factors live in the new `sm-mfa-plugin`; per D-03 the device flow stays in sm-user-plugin.
**Verified:** 2026-10-26T16:05:13Z (refreshed after bc8f954; first pass 15:58:30Z)
**Status:** human_needed
**Re-verification:** No, initial verification, refreshed once after the gate change bc8f954 (no prior VERIFICATION had gaps)
**MVP-mode note:** ROADMAP marks the phase `mode: mvp`, but the goal is not a user story. Every plan records the decision not to invent one (as in 19.5), so this report runs standard goal-backward verification against SC-1..SC-4, D-01..D-20 (with D-12a/b/c) and the 41 plan must-haves. No "User Flow Coverage" table is produced; the user-facing demo flow is human item 2.
**Release state, checked in the repositories.** sm-user-plugin `v0.1.13` = 07196a4, sm-mfa-plugin `v0.1.2` = 463c750 and sm-sociallogin-plugin `v0.1.5` = ecfb8a7 exist on the remotes, and each remote `master` equals its tag. Each checkout is clean at that commit. sm-demo-app `origin/master` = 8fcdbdd: its gitlinks are the three tag commits, and go.mod requires v0.1.13, v0.1.2 and v0.1.5. sm-bm-app's HEAD carries the same gitlinks, but the bumps (56a79ab, 1c1db04, 604b06c) sit among 39 unpushed commits, so `origin/master` ebb2266 still pins user 3b22134 (v0.1.12), mfa 33ad164 (v0.1.0) and sociallogin b752177 (v0.1.4). The orchestrator says this is intentional, and I count it as a recorded decision, not a gap. **A deploy from bm's origin therefore lacks 71d8f27 (an untouched settings form disabled every method) and every review fix, CR-01 included.** fonoteka.go e36577a bumps the user gitlink to 07196a4 and is never pushed, by design.
## Goal Achievement
### Observable Truths
Abbreviations: **U** sm-user-plugin, **M** sm-mfa-plugin, **S** sm-sociallogin-plugin, **D** sm-demo-app/vue-demo-app, **B** sm-bm-app, **A** fonoteka.go. "Gate" means my own run of `PHASE196_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.6.sh --all` at the released pins. It exited 0, and all 11 stages passed.
**Roadmap success criteria**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC-1 | TOTP setup/confirm/disable, WebAuthn registration and login challenges, email codes, recovery codes and trusted devices work against the PHP contract; the WebAuthn dependency has a decision note before code lands | VERIFIED (browser interop: human item 1) | M `routes.go` mounts PHP's 5 challenge routes and all 13 management routes, compared one by one with PHP `routes.php:303-336,372-453`. Note `24d9d2a` (09:49) lands before M's first go.mod commit `5c6ad2f` (11:05) and the go-webauthn require `2662252` (12:32). Gate `--mfa` and `--docker`: TestTOTP, TestQRSVG, TestRecovery, TestEmailCode, TestTrustedDevice, TestWebAuthn, TestPasswordless and TestPHPErrorBodies all PASS. The WebAuthn tests use a software authenticator with real ECDSA and CBOR (`webauthn_authenticator_test.go`). |
| SC-2 | Login with a second factor returns the unauthenticated challenge step, rate-limited by `2fa-verify` | VERIFIED | U `controllers/api_controller.go:139-157` consults the gates and answers the gate body. M `classes/gate.go` returns `{two_factor_required, challenge_token, available_methods, expires_at}`. M `buckets.go` sets 10/min per client IP, and `routes.go` puts the challenge group under `throttle:2fa-verify`. TestGate, TestLoginGateSmoke and TestRoutesAndBuckets (11th request 429) PASS. |
| SC-3 | `devices/initiate`, `devices/status/{token}`, `devices/authorize`, device list and revoke work on `DeviceAuthSession` | VERIFIED | U `routes.go` mounts the 5 routes in the user-api group. U `models/device_auth_session.go` and migration `202610260001_create_device_auth_sessions` match PHP's columns (v2.1.0-v2.7.1) plus `jwt_jti`. TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize, TestDeviceHandOverConcurrent, TestDeviceListAndRevoke, TestDeviceRevokeAfterRefresh and TestDeviceFlowSmoke PASS in the gate. Since bc8f954 the gate also requires TestDeviceRevokeRacesRefresh and TestDeviceAccessRevocation: both PASS. |
| SC-4 | Six models and migrations ported; a security review; unit tests in the last plan | VERIFIED | M `models/` has TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice and WebAuthnCredential; U has DeviceAuthSession. Migrations M `202610220001..0006` and U `202610260001` carry PHP's table and column names (compared with PHP `updates/v3.0.0`, `v3.1.0`, `v2.1.0`). `19.6-SECURITY-REVIEW.md` maps T-19.6-01..38 with a D-14 section. Plan 04 delivered 41 named tests and the review round 6 more; the gate runs 17+1 U, 24 M and 3 S Postgres tests plus the Docker-free names. |
**Plan 01 (sm-user-plugin seams and device flow)**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1.1 | D-04 decision note with the three pins, the measurements and the human check; STACK rows; no go.mod of U, framework or A requires the libraries | VERIFIED | The note names each pin once. STACK.md references the note 3 times. `grep -c` gives 0 in U go.mod and framework go.mod. `go list -m all` in A shows no webauthn, pquerna or boombuler module. |
| 1.2 | D-02: registry published in Register; Login consults it after CheckAndRecordLogin and the rehash, before the restore and the mint; a gate error answers the opaque 500; refresh never asks | VERIFIED | `api_controller.go:112-157`. `Refresh` has no CheckLoginGates call. TestLoginGates (also with `-race`) and TestLoginGateOrder PASS. |
| 1.3 | D-18 A10: a challenged soft-deleted account stays deleted, with no reactivate mail | VERIFIED | The restore at `:158-164` runs after the gate return. TestLoginGateOrder/challenged_soft_deleted_stays_deleted PASS (gate). |
| 1.4 | LoginEvent fires once after the mint, none on a challenge; FireLoginEvent and CheckLoginGates are exported | VERIFIED | `:179`. TestLoginGateSmoke no_gate and challenge PASS. |
| 1.5 | D-07: the password-change hook fires in the write transaction for reset, change and admin set; a listener error rolls back; the rehash fires none | VERIFIED (recorded deviation) | Built on the existing `AccessRevokedEvent` with `PasswordChanged()` instead of a new `PasswordChangedEvent` (01-SUMMARY deviation 1). TestPasswordChangedSources PASS. |
| 1.6 | D-15: permission `golem15.users.reset_two_factor`, Danger action, offered only with a published TwoFactorAdmin, Run in cabana's transaction | VERIFIED | `users_admin_controller.go:820-847` (`Lookup[classes.TwoFactorAdmin]`, plus the WR-04 privileged guard). `config_form.yaml:17`. TestResetTwoFactorAction PASS. |
| 1.7 | D-03/D-11/D-16: 5 routes, PHP columns, SHA-256 token, XXXX-XXXX code, 5-minute TTL, status enum, `jwt_jti`, configurable auth_url | VERIFIED (two recorded deviations) | Migration ID `202610260001` instead of the planned `202610210002` (04-SUMMARY deviation 5). The auth_url default is now `{frontend_url}/device/scan/{short_code}` instead of D-11's `{token}` (WR-01, released in v0.1.13, user decision in DISPOSITION). Authorize accepts a short code in `token`, so QuestStream's `useDeviceAuth.ts`/`useProfile.ts` keep working. |
| 1.8 | D-12a: the first poll after confirm gets `{status:'confirmed', user_id, message, token, user}` through one guarded UPDATE; concurrent polls give one winner; used and revoked never expire | VERIFIED | `devices.go:298-300` (`WHERE status='confirmed' AND expires_at > NOW()`). `ExpiredAt` exempts used and revoked. TestDeviceHandOverConcurrent (50 polls) PASS. Since WR-01 the hand-over also requires the initiating User-Agent. |
| 1.9 | D-12b: revoke blacklists the jti; refresh follows the jti | VERIFIED | `DeviceDestroy` runs `UPDATE … RETURNING jwt_jti` and calls `Lookup[bouncer.BlacklistStore]` (`devices.go:534-552`). `Refresh` calls `FollowDeviceJTI` and fails closed (`api_controller.go:316-333`, CR-01). TestDeviceRevokeAfterRefresh and TestDeviceRevokeRacesRefresh PASS (gate, since bc8f954). |
| 1.10 | D-12c: authorize needs only a valid bearer JWT; one guarded pending-to-confirmed UPDATE; no 2FA re-check | VERIFIED | `devices.go:412-414`. TestDeviceAuthorize PASS. |
| 1.11 | D-17: every change to U is additive; S and A compile | VERIFIED | Gate additive check: "no exported declaration removed or changed … since 50da92c (63 declarations added; 1 allowed migration)". A builds in the fonoteka stage. S and M build with `GOWORK=off` at their own requires (exit 0, my run). |
**Plan 02 (sm-mfa-plugin core)**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 2.1 | D-01: module `git.golem15.com/golem15/sm-mfa-plugin`, plugin `golem15.mfa` requires `golem15.user`; Boot needs no database | VERIFIED | `plugin.go:72-89`: Boot only adds the gate, publishes TwoFactorAdmin and listens. Gate `--mfa` checks the pins. |
| 2.2 | SC-2: challenge body and the 2fa-verify bucket | VERIFIED | Same evidence as SC-2. |
| 2.3 | D-05: PHP 401 and 422 bodies; TTL 5, 5 attempts, one completion | VERIFIED | `classes/challenge.go:96,119,137` (guarded `attempts < max_attempts`, `completed_at IS NULL`). TestChallengeLifecycle (20 parallel verifies, one token) and TestPHPErrorBodies PASS. Plus WR-02 (TestChallengeAfterRevocation) and WR-07 (TestCompletionFailureSpendsNothing), both required by the gate since bc8f954 and PASS. |
| 2.4 | D-04/D-05: TOTP setup `{secret, qr_code_svg, qr_code_url}`, ±1 step, encrypted secret, replay refused, disable | VERIFIED | `controllers/management.go:78-80`, `classes/totp.go`, `classes/qr.go`. TestTOTP (replay), TestQRSVG and TestTokensAtRest PASS. |
| 2.5 | D-05/D-19: recovery codes, 10 × `XXXXX-XXXXX`, bcrypt, single use, count only, regenerate, first set returned | VERIFIED | `classes/recovery.go:19-26,136`. TestRecovery and TestRecoveryCodeFormat PASS. |
| 2.6 | D-05/D-18 A8 and A9: 6-digit bcrypt email code, template `golem15.mfa::mail.two_factor_code` (and -en), gating, 3 sends, `expiry_minutes` = min | VERIFIED | `classes/email.go:22,102`, `mail.go:22-23`. TestEmailCode and TestEmailExpiryMinutes PASS. Code lifetime is bound to the challenge, as PHP's `EmailMethod::verify` is (IN-02 is PHP parity). |
| 2.7 | D-18 A6: 10 failures in 15 minutes per user refuse new challenges and verifies | VERIFIED | `gate.go` uses `FailureCapReached` (`challenge.go:218`). TestFailureCap PASS. |
| 2.8 | D-06: tokens as SHA-256, TOTP secret encrypted, codes bcrypt, nothing logged | VERIFIED | TestTokensAtRest PASS. My log sweep found no token, code or secret attribute in M's log calls. |
| 2.9 | D-07/D-10: trusted devices off by default, 30 days, revoked on a password change and when the last method goes, revoke routes | VERIFIED | TestTrustedDevice, TestRevocation and TestTrustedSettingsSmoke PASS. |
| 2.10 | D-12/D-13/D-09: Settings screen with PHP keys and defaults, applied per request, only `disabled` exempts | VERIFIED | `models/settings.go`, `classes/config.go`. No child exemption anywhere in M (grep). TestSettings (including form_round_trip, fix 71d8f27) and TestPHPUser005 PASS. |
| 2.11 | D-15: TwoFactorAdmin published; reset disables methods, deletes codes and devices, fires disabled | VERIFIED | `plugin.go:82`. TestAdminReset PASS. |
| 2.12 | D-16: four tables plus settings with PHP names and columns (+ `email_sends`) | VERIFIED | DDL compared with PHP v3.0.0 and v3.1.0: same columns, token as VARCHAR(64). TestMigrations PASS. |
**Plan 03 (WebAuthn, passkeys, social gate, demo, release)**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 3.1 | D-04/D-08: go-webauthn v0.18.2 only in M; standard base64url shapes; attestation none, 60 s, UV preferred | VERIFIED (browser: human item 1) | TestWebAuthnShapes and TestWebAuthnCeremonySmoke PASS. A's module graph has no go-webauthn. |
| 3.2 | D-05/D-06: WebAuthn state server-side, consumed once; PHP register, verify and remove bodies | VERIFIED | `cache.Pull` in `classes/webauthn.go:318` and `classes/passwordless.go:69`. TestWebAuthn and TestWebAuthnShapes/registration_state PASS. |
| 3.3 | D-16: `golem15_user_webauthn_credentials` with PHP columns + `credential_data`, `passwordless`, unique `credential_id` | VERIFIED | Migration `202610220006`. TestMigrations PASS. |
| 3.4 | D-14: passwordless routes in the bucket, 403 while off, passkeys only, UV required, activation, ban and suspension enforced, Source passkey, no second challenge | VERIFIED | SECURITY-REVIEW "D-14" table. TestPasswordless and TestPasswordlessSmoke PASS. The WR-05 partial (passkeys demoted on a password change) is pinned by TestPasswordless/password_change_demotes_passkeys. |
| 3.5 | D-09: S consults the gate in signIn (Source social), skipped by default, the challenge body on both completions, refusals and errors fail closed, link never gated | VERIFIED | S `classes/flow.go:343-359`. TestSocialGate PASS (gate). WR-08 TestChallengedSignInFiresOnCompletion PASS (gate, since bc8f954). |
| 3.6 | D-20: the demo sets up TOTP with QR and confirm, shows recovery codes once, disables, and answers the login challenge with TOTP or a recovery code; bm stays API-only | VERIFIED (code and unit; browser: human item 2) | D `pages/account/security.vue` uses `useTwoFactor` (`totp/setup`, `confirm`, `DELETE totp`) and renders `<img :src="svgDataUrl(...)">`. `login.vue` and `useDemoAuth.ts` call `2fa/verify` and `2fa/recovery`. The gate's demo-web stage requires `twoFactor.test.ts`. B `frontend/src` has no 2FA code (grep). |
| 3.7 | D-17: release order, D and B mount mfa and bump pointers in separate commits, A bumps user and updates TestNoDeferredRoutesResolve without mounting mfa | VERIFIED (B unpushed, recorded) | Remote tags and heads as in "Release state". D `summer.yaml:40` and `plugins.gen.go:12,44`; B `summer.yaml:8` and `plugins.gen.go:9,21`. A `phase07_coverage_test.go:97-100` drops `/devices` and keeps `2fa`. A's `summer.yaml` has no mfa. |
| 3.8 | D-01: A has no /2fa route, no 2FA table, no second-factor module | VERIFIED | Gate `--fonoteka` and my own `go list -m all`. |
**Plan 04 (unit tests, security review, gate, post-release fixes)**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 4.1 | SC-4: unit tests for every U seam, every M decision and S's gate, on Postgres where needed | VERIFIED | Gate `--docker` (after bc8f954): 17 U root, 1 U updates, 24 M and 3 S tests by name. The Docker-free names pass in their stages. |
| 4.2 | D-02/D-18 A10 table tests | VERIFIED | TestLoginGates (`-race`) and TestLoginGateOrder PASS. |
| 4.3 | Device-flow table tests | VERIFIED | The 8 device tests and TestDeviceAuthSessionsMigration PASS. |
| 4.4 | M tests pin every PHP body and decision | VERIFIED | TestPHPErrorBodies compares error and 422 bodies byte for byte. Success bodies are compared by key set because Go sorts keys (recorded observation). |
| 4.5 | D-08/D-14 tests and the review's D-14 section | VERIFIED | `19.6-SECURITY-REVIEW.md` "D-14" maps each rule to a subtest. |
| 4.6 | TestEveryLoginPathIsGated and TestSocialGate | VERIFIED | `login_paths_test.go:29-52` lists the mint sites; ActivateByCode points to F-1 (deferred by the user). PASS. |
| 4.7 | PHP map: TestPHPMethodMap fails on missing, unknown or extra methods; test_user_005 maps to TestPHPUser005 | VERIFIED | Both run in the `--mfa` stage: PASS. |
| 4.8 | The security review maps every T-19.6 threat to a test or an accepted disposition | VERIFIED | T-19.6-01..38 plus 4 SC rows; F-1 deferred with a corrected reason. |
| 4.9 | The gate fails closed (fail, skip, zero tests, missing names); `--self-test`; docker NOT RUN is fatal with REQUIRE_DOCKER | VERIFIED | `--self-test` PASS in my run. My `--all` with `PHASE196_REQUIRE_DOCKER=1`: exit 0. |
| 4.10 | Every post-release production fix is in every pin that consumes it, or tracked | VERIFIED | 71d8f27 is in v0.1.1 and later. The review fixes are in v0.1.13, v0.1.2 and v0.1.5, and the gitlinks of D, B (local HEAD) and A equal those tag commits. B's push is held, and WR-05 and F-1 are deferred, all by user decision. |
**Score:** 45/45 truths verified (0 present, behavior-unverified). Every behavior-dependent truth (guarded UPDATE races, revoke versus refresh, restore ordering, transaction rollbacks) has a named passing test. The race tests were mutation-checked per the security review.
### Required Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `.planning/notes/2026-10-21-mfa-otp-webauthn.md` | VERIFIED | Committed in 24d9d2a before any require |
| U `classes/login_gate.go` | VERIFIED | `type LoginGate interface`, a registry with RWMutex and clone |
| U `controllers/devices.go` | VERIFIED | 573 lines; `DeviceStatus`, hand-over, authorize, destroy |
| U `updates/202610210002_create_device_auth_sessions.go` | VERIFIED (renamed) | Exists as `202610260001_create_device_auth_sessions.go` (deviation 04-5). The verify.artifacts tool reports the planned path missing. |
| U `login_gate_smoke_test.go` | VERIFIED | Run by name in the gate |
| M `plugin.go`, `classes/challenge.go`, `classes/qr.go`, `buckets.go`, `tracer_smoke_test.go` | VERIFIED | verify.artifacts 5/5 |
| M `classes/webauthn.go`, `classes/passwordless.go`, `updates/202610220006_*` | VERIFIED | |
| S `classes/flow.go` | VERIFIED | `CheckLoginGates` |
| D `vue-demo-app/app/pages/account/security.vue` | VERIFIED | The tool flags the missing literal `totp/setup`. The page calls `useTwoFactor().setupTotp()`, which posts to `/_user/api/v1/2fa/totp/setup` (`composables/useTwoFactor.ts`). Wired. |
| D `mfa_test.go` (TestMFAMounted) | VERIFIED | PASS in `--demo` |
| `scripts/check-phase19.6.sh` | VERIFIED | My `--all` run exited 0 with the script before bc8f954; after bc8f954 (six names added to the docker lists, `bash -n` clean) `--self-test` and `--docker` exit 0 |
| M `login_paths_test.go`, `testdata/PHP_METHOD_MAP.md`, `19.6-SECURITY-REVIEW.md` | VERIFIED | verify.artifacts 4/4 |
### Key Link Verification
The `verify.key-links` tool rejects paths outside this repository, so I checked every link by hand with grep.
| From | To | Via | Status |
|------|----|-----|--------|
| U `api_controller.go` | `classes/login_gate.go` | `CheckLoginGates(` in Login (1 call) | WIRED |
| U `api_controller.go` | `jwt_jti` | `classes.FollowDeviceJTI` in Refresh (`HandOverDeviceJTI` wraps it) | WIRED |
| U `devices.go` | `bouncer.BlacklistStore` | `app.Lookup[bouncer.BlacklistStore]()` `:547` | WIRED |
| U `users_admin_controller.go` | `TwoFactorAdmin` | `app.Lookup[classes.TwoFactorAdmin]()` `:843` | WIRED |
| M `plugin.go` | U `LoginGates` | `Lookup[*userclasses.LoginGates]` then `gates.Add(&classes.Gate{})`; Boot fails if the registry is absent | WIRED |
| M `classes/login.go` | U helpers | `FinishLogin`, `IssueToken`, `APIArray`, `FireLoginEvent` | WIRED |
| M `plugin.go` | U events | `Listen[*userclasses.AccessRevokedEvent]` (the plan named PasswordChangedEvent; deviation 01-1) | WIRED |
| M `routes.go` | M `buckets.go` | `surf.Use("throttle:2fa-verify")` | WIRED |
| M `classes/webauthn.go` | `cooler` | `cache.Pull` | WIRED |
| S `classes/flow.go` | U `login_gates.go` | `Source: userclasses.LoginSourceSocial` | WIRED |
| D `useDemoAuth.ts` | `/_user/api/v1/2fa/verify` | `$fetch` | WIRED |
| D `summer.yaml` | M | `golem15.mfa`, `plugins.gen.go` | WIRED |
| M | D `plugins/golem15/mfa` | gitlink 463c750 = v0.1.2 | WIRED |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|----------|------|--------|-----------|--------|
| Login challenge body | `challenge_token`, `available_methods`, `expires_at` | `CreateChallenge` inserts a row; `EnabledMethods` queries `golem15_user_two_factor_methods` | yes | FLOWING |
| `2fa/status` | methods, trusted devices, WebAuthn credentials, remaining count | `classes/methods.go` status queries | yes | FLOWING |
| Device status hand-over | `token`, `user` | `mintWithJTI` + `apiArray` after the guarded UPDATE on `golem15_user_device_auth_sessions` | yes | FLOWING |
| Demo security page | `state`, `setup.qr_code_svg` | `useTwoFactor` → `2fa/status`, `totp/setup` | yes | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Whole phase gate at the released pins, Docker required | `PHASE196_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.6.sh --all` | exit 0; self-test, go, userplugin (5 + 1 `-race` + 1, additive check), mfa (11), sociallogin (9), demo (3), demo-web, bm (2), fonoteka, docs, docker (15 + 1 U, 21 M, 2 S) | PASS |
| Gate after bc8f954: detectors and Docker stage | `bash scripts/check-phase19.6.sh --self-test`; `PHASE196_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.6.sh --docker` | self-test passed; docker exit 0: 17 + 1 U, 24 M, 3 S tests by name. Only the docker lists changed, so no other stage is affected. | PASS |
| Review-fix regression tests, run by name before bc8f954 (U) | `go test -count=1 -run '^(TestDeviceRevokeRacesRefresh\|TestDeviceAccessRevocation)$' ./plugins/golem15/user` (fonoteka.go) | both PASS | PASS |
| Review-fix regression tests, run by name before bc8f954 (M, S) | `go test -run '^(TestChallengeAfterRevocation\|TestPasswordConfirmationThrottle\|TestCompletionFailureSpendsNothing)$' .` in M; `-run '^TestChallengedSignInFiresOnCompletion$'` in S | all PASS (4 + 2 + 2 subtests) | PASS |
| Demo module-mode build at the tags | `GOWORK=off GOFLAGS=-mod=readonly go build -trimpath .` (sm-demo-app) | ok; resolves user v0.1.13, mfa v0.1.2, sociallogin v0.1.5 | PASS |
| M and S build at their own go.mod requires (user v0.1.12) | `GOWORK=off go build ./...` | exit 0 both | PASS |
| A carries no second-factor module | `go list -m all \| grep -E 'webauthn\|pquerna\|boombuler'` (fonoteka.go) | no match | PASS |
### Probe Execution
No `scripts/*/tests/probe-*.sh` is declared or exists for this phase. The phase gate (above) is the runnable check.
### Requirements Coverage
| ID | Source Plan | Status | Evidence |
|----|-------------|--------|----------|
| SC-1 | 02, 03 | SATISFIED (browser interop human) | Truths SC-1, 2.4-2.6, 2.9, 3.1-3.3 |
| SC-2 | 01, 02, 03 | SATISFIED | SC-2, 1.2, 2.2 |
| SC-3 | 01 | SATISFIED | SC-3, 1.7-1.10 |
| SC-4 | 01-04 | SATISFIED | SC-4, 4.1-4.9 |
| D-01 | 02, 03 | SATISFIED | 2.1, 3.8 |
| D-02 | 01, 03, 04 | SATISFIED | 1.2, 1.4 |
| D-03 | 01 | SATISFIED | 1.7 |
| D-04 | 01, 02, 03 | SATISFIED | 1.1, 2.4, 3.1 (boombuler/barcode with its own SVG writer) |
| D-05 | 02, 03, 04 | SATISFIED | 2.3-2.6, 3.2 (mail template now `golem15.mfa::`, as the plugin moved) |
| D-06 | 01, 02, 03, 04 | SATISFIED | 2.8, TestDeviceTokensAtRest |
| D-07 | 01, 02, 04 | SATISFIED | 1.5, 2.9 |
| D-08 | 03, 04 | SATISFIED (browser human) | 3.1 |
| D-09 | 02, 03, 04 | SATISFIED | 3.5, 2.10 |
| D-10 | 02, 03, 04 | SATISFIED | 2.9 |
| D-11 | 01, 04 | SATISFIED (default auth_url changed by WR-01) | 1.7 |
| D-12 | 02, 04 | SATISFIED | 2.10 |
| D-12a | 01, 04 | SATISFIED | 1.8 |
| D-12b | 01, 04 | SATISFIED | 1.9 |
| D-12c | 01, 04 | SATISFIED | 1.10 |
| D-13 | 02, 04 | SATISFIED | 2.10 |
| D-14 | 03, 04 | SATISFIED | 3.4 |
| D-15 | 01, 02, 04 | SATISFIED | 1.6, 2.11 |
| D-16 | 01, 02, 03, 04 | SATISFIED | 2.12, 3.3, SC-4 |
| D-17 | 01, 03, 04 | SATISFIED (B push held) | 1.11, 3.7, 4.10 |
| D-18 | 01, 02, 04 | SATISFIED | 1.3, 2.6, 2.7 |
| D-19 | 02, 03, 04 | SATISFIED | 2.5 |
| D-20 | 03 | SATISFIED (browser human) | 3.6 |
REQUIREMENTS.md maps no IDs to this phase ("Requirements: TBD"), so no requirement is orphaned. Every ID in CONTEXT is claimed by at least one plan.
### Decision Coverage
`check.decision-coverage-verify`: 23/23 trackable CONTEXT decisions honored by the shipped artifacts (not_honored: none). Non-blocking gate.
### Test Quality Audit
| Area | Active | Skipped | Circular | Assertion level | Verdict |
|------|--------|---------|----------|-----------------|---------|
| U gate, device and password tests | 17 + 1 Docker, 7 Docker-free | 0 in the gate run; `t.Skip` only under `-short`, and the gate counts a skip as a failure | no | behavioral (races, transactions, bodies) | OK |
| M decision tests | 24 Docker, 11 Docker-free | 0 | no; PHP bodies are literals from PHP sources, PHP_METHOD_MAP from PHP's test file | value and behavioral | OK |
| S gate tests | 3 Docker (WR-08 included) | 0 | no | behavioral | OK |
| D `twoFactor.test.ts` | in the demo-web vitest run | 0 | no | value (`svgDataUrl` has no `<`) | OK |
Disabled tests on requirements: 0. Circular patterns: 0. Insufficient assertions: 0. The first pass warned that the review round's six regression tests (TestDeviceRevokeRacesRefresh, TestDeviceAccessRevocation, TestChallengeAfterRevocation, TestPasswordConfirmationThrottle, TestCompletionFailureSpendsNothing, TestChallengedSignInFiresOnCompletion) were outside the gate's required-name lists. **Resolved by bc8f954:** the gate now requires all six, and its `--docker` stage passes with them.
### Anti-Patterns Found
I scanned the 137 files the phase touched (U `50da92c..07196a4`, all of M, S `v0.1.3..v0.1.5`):
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (none) | - | TBD / FIXME / XXX | - | No debt marker. `XXXXX-XXXXX` format strings excluded. |
| (none) | - | TODO / HACK / placeholder | - | None |
| M, U log calls | - | secrets in logs | - | Only ids and `err` attributes (matches the review's "Log calls" sweep) |
| sm-bm-app | - | release pins only local | ℹ️ Info | origin runs mfa v0.1.0 and user v0.1.12, which lack 71d8f27 and the review fixes, until bm is pushed (recorded decision) |
| A `go.mod` | 11 | requires sm-user-plugin v0.1.10 | ℹ️ Info | resolved through `replace`; open item in deferred-items.md |
| M, S `go.mod` | 6 | require sm-user-plugin v0.1.12 | ℹ️ Info | `GOWORK=off` builds pass; the apps select v0.1.13 by MVS |
| `19.6-REVIEW.md` IN-01..IN-10 | - | info findings | ℹ️ Info | Open outside the fix scope the user chose. None contradicts an SC; IN-02 matches PHP's behaviour. |
### Recorded deferrals (user decisions, not gaps)
- **WR-05** (factor changes need only a session): partial mitigation in M 463c750; the full fix needs an API contract change. Todo `2026-10-26-mfa-factor-changes-need-reauth.md` (g15office krt3vrts). It does not contradict SC-1, because PHP's contract has the same exposure.
- **F-1** (activate-by-code mints without the second factor when `require_activation: false`): todo `2026-10-26-activate-by-code-second-factor.md`. It needs a non-default configuration. SC-2 holds for the login path it names, and TestEveryLoginPathIsGated pins the mint site with its pointer.
- **sm-bm-app push**: held because its unpushed range carries another session's commits.
### Prohibitions (judgment-tier, non-authoritative LLM-judge verdicts)
| Plan | Prohibition | Verdict |
|------|-------------|---------|
| 01 | No JWT, device token, short code, challenge token or password in a log line or error string | No violation found (log sweep of U `devices.go` and `api_controller.go`) |
| 01 | sm-user-plugin changes additive only | No violation found (gate additive check against `50da92c`; the WR-01 default change concerns a key this phase introduced) |
| 01 | No go.mod of U, framework or A requires the three libraries | No violation found |
| 01 | Nothing pushed or tagged in plan 01 | No violation found (process; SUMMARY-attested) |
| 01 | Framework code and READMEs name no consuming application | No violation found (gate docs: 503 files checked) |
| 02 | No token, code, secret or password logged | No violation found |
| 02 | goccy only; no `gopkg.in/yaml.v3` | No violation found (gate `--mfa` deps; grep) |
| 02 | No notification mail, per-group exemption, Winter import or child exemption | No violation found (grep) |
| 02 | Nothing pushed in plan 02 | No violation found (process) |
| 02 | U unchanged by plan 02 except for defect fixes | No violation found (SUMMARY-attested) |
| 03 | No assertion, attestation, challenge, token or library error logged or echoed | No violation found |
| 03 | QR SVG rendered as an image data URL, never `v-html` | No violation found (`security.vue:130`; the existing `v-html` uses are in unrelated deck components) |
| 03 | No remote operation before the go-ahead; no force-push; A never pushed; no deploy | No violation found (A is 38 commits ahead of origin) |
| 03 | A does not mount mfa; B's frontend has no 2FA UI | No violation found |
| 03 | The stale `../vue-demo-app` is not used | No violation found (commit 44a5229 lives in `sm-demo-app/vue-demo-app`, origin golem15/vue-demo-app) |
| 04 | No production change except defect fixes | No violation found (71d8f27 only) |
| 04 | No real secrets, authenticators or mail servers in tests | No violation found (virtual authenticator, capturing mailer) |
| 04 | A skipped Docker test never counts as passed | No violation found (detector self-test PASS) |
| 04 | Pushes and tags only as authorised; nothing force-pushed or deployed | No violation found (process; SUMMARY-attested) |
### Human Verification Required
These three items are seeded into `19.6-UAT.md` (status testing, 3 pending). That file records no results yet, so Step 8b settles none of them.
#### 1. Real-browser WebAuthn ceremony
**Test:** On an instance with `webauthn` in `two_factor_available_methods`, served over HTTPS with a hostname RP ID, register a security key and complete a login challenge with it (`2fa/webauthn/register/options` → `navigator.credentials.create` → `2fa/webauthn/register`; then `2fa/webauthn/options` → `navigator.credentials.get` → `2fa/webauthn/verify`). With `two_factor_passwordless_login` on, register a passkey and sign in through `2fa/webauthn/passwordless/options` and `/verify`.
**Expected:** PHP's register body, `{token, user}` on verify, a passkey token with no second challenge, and a second-factor-only key refused for passwordless.
**Why human:** The software-authenticator tests prove the server; only a browser proves the D-08 JSON shapes and the origin and RP ID configuration end to end. The demo cannot run this as configured (no WebAuthn screens, webauthn not offered), so a small client page is needed.
#### 2. Demo TOTP flow in a browser (D-20)
**Test:** Sign in on the demo, enable TOTP from the QR with an authenticator app, keep the recovery codes, sign out, answer the challenge with an app code and then with a recovery code, then disable TOTP.
**Expected:** The QR scans, the recovery codes show once, the challenge step completes with either code, a used recovery code is refused, and after disable login is direct.
**Why human:** User-facing look and flow; plans 03 and 04 record the browser run as NOT RUN.
#### 3. Judgment-tier prohibitions
**Test:** Review the 19 verdicts in "Prohibitions" above.
**Expected:** Confirm or dispute each.
**Why human:** ADR-550 D4: judgment-tier items never pass silently.
### Gaps Summary
No gaps. Every roadmap criterion, every CONTEXT decision and every plan must-have is backed by code at the released tags and by named tests that pass in my own run of the phase gate with Docker required, including the six review-fix regression tests the gate requires since bc8f954. The deviations from plan wording (AccessRevokedEvent for D-07, migration ID `202610260001`, the auth_url default changed by WR-01) are recorded in the SUMMARYs or the review disposition and keep each decision's intent. Before `passed`, three human items remain: a real-browser WebAuthn ceremony, the demo's TOTP flow in a browser, and the prohibition verdicts. Worth knowing beyond those: sm-bm-app's origin still runs the pre-review plugin versions until its held push. The first pass also noted that the gate did not require the review round's regression tests; bc8f954 resolved that (re-verified: `--self-test` and `--docker` pass). The three human items are seeded into `19.6-UAT.md` (3 pending, no results yet, so Step 8b settles none).
---
_Verified: 2026-10-26T16:05:13Z (refreshed after bc8f954)_
_Verifier: Claude (msd-verifier)_

View File

@@ -0,0 +1,37 @@
## Deferred Items
- sm-sociallogin-plugin: `signin_event_test.go` is not gofmt-clean at origin/master `4d3b2dd` (v0.1.3)
status: open
**What:** `gofmt -l .` lists it: `logRecorder.Enabled` is a one-line method gofmt now splits. It came with `4d3b2dd` from another session. Plan 19.6-03 did not touch it. Fix it in a `style:` commit in sm-sociallogin-plugin.
- vue-demo-app has no type-check tooling
status: open
**What:** the Nuxt build does not type-check, and `vue-tsc` is not a dependency, so 19.6-03 Task 3 checked the new page, composables and header with vitest and `pnpm build` only. The user decides whether to add `nuxi typecheck`.
- fonoteka.go: five Docker-backed tests in `plugins/golem15/fonoteka` fail without `-short`, at the committed user pointer v0.1.10 as well as at v0.1.12
status: open
**What:** found by 19.6-03 Task 6 (baseline run with the user submodule at `bfcfea4`; the failures are identical):
- `TestPhase09SecurityRoutes`: unexpected cabana admin routes (account/contributions, account/preferences, reorder, singleton) from the framework working tree.
- `TestPhase8RedMCPMe`, `TestOAuthToolsMeRouteIsolation`: `GET /_user/api/v1/token/me` counts as a `/me`-suffixed route on another surface.
- `TestMustChangePasswordLock`: login answers 401 "Invalid email or password".
- `TestPhase14Edges/prune-cutoff-90-days-then-one-second-older`: prune leaves the "past" row.
None of these tests inventories user-plugin tables, routes or models, so 19.6-03 did not change them. The `-short` suites are green. The parity inventories (`parity/migrate_test.go`, `parity/schema_diff_test.go`) were also red at v0.1.10; `95fd58a` fixed them along with the bump.
- fonoteka.go: root `go.mod` still requires `sm-user-plugin v0.1.10`
status: open
**What:** the module resolves through `replace => ./plugins/golem15/user`, so builds and tests use the v0.1.12 checkout. 19.6-03 Task 6 staged only the paths the plan names. Precedent `06bc9d0` raised the require together with the pointer. Raise it to v0.1.12 (and tidy) in a follow-up `chore:` commit.
- sm-mfa-plugin: the v0.1.0 tag message says the two-factor mode is "optional, required or disabled"
status: resolved (2026-10-26: the v0.1.1 tag message names the modes disabled, optional and enforced)
**What:** the config values are `disabled`, `optional` and `enforced`. The tag is pushed and is not rewritten (that would need a force-push). The README and `config/config.yaml` are correct. The next release's tag message should name the right values.
- sm-demo-app: the release build needs read access to the private sm-mfa-plugin repository
status: resolved (2026-10-26: the user made golem15/sm-mfa-plugin public; anonymous HTTPS `git ls-remote` of v0.1.0 works)
**What:** found by 19.6-03 Task 6 part (1). `scripts/build.sh release` and `scripts/check-pins.sh` run with `GOWORK=off GOPRIVATE=git.golem15.com/*`, so Go fetches every required plugin over anonymous HTTPS. The other `sm-*-plugin` repositories allow that; sm-mfa-plugin is private, so the fetch fails ("could not read Username for 'https://git.golem15.com'"). On this machine the release build and the pins check pass, because v0.1.0 is already in the module cache: `go get` filled it through a one-command `url.git@git.golem15.com:.insteadOf https://git.golem15.com/` rewrite in the environment, and no global config was changed. A clean module cache, or another build machine, fails. The user decides: make sm-mfa-plugin public like the other plugins, or give the build host credentials (an `insteadOf` rewrite to SSH or a netrc) and document it in DEPLOY.md. sm-bm-app is not affected (it builds from the submodule through a replace).
- sm-demo-app: uncommitted journal and oauthserver submodule moves left over from Phase 19.7 plan 05
status: open
**What:** tracked as the pending todo `.planning/todos/pending/2026-10-26-sm-demo-app-journal-oauthserver-pointers.md`. 19.6-03 left both unstaged.
- sm-user-plugin: the `classes` package starts Postgres under `-short`
status: open
**What:** found by 19.6-04. `classes/throttle_test.go` `throttleDB` (used by `TestCodes` and other `classes` tests) starts a testcontainers Postgres without checking `testing.Short()`, so `go test -short ./...` of the user plugin needs Docker (about 45 s on this host) and fails on a host without it. Predates this phase. 19.6-04 fixed the same problem in the `updates` package (`2c68979`, its TestMain scanned `os.Args` for `-test.short`). Fix it the same way in a `test:` commit.
- sm-mfa-plugin and sm-user-plugin: multi-key success bodies carry Go's sorted key order
status: open (observation)
**What:** found by 19.6-04 `TestPHPErrorBodies`. Bodies built from Go maps (the login challenge, `2fa/status`, `totp/setup`, `email/send`, the WebAuthn register answer, the device initiate and status bodies) list their keys alphabetically, where PHP's `response()->json([...])` kept insertion order. Values and key sets match PHP; error bodies and the 422 bodies are byte-identical. JSON clients do not depend on key order, so nothing was changed; recorded in 19.6-SECURITY-REVIEW.md "Residual risks" for a decision if a client ever compares raw bytes.
- sm-user-plugin: activate-by-code mints a session without the second factor when `require_activation` is off (finding F-1)
status: deferred (user decision at plan 04 Task 5)
**What:** see 19.6-SECURITY-REVIEW.md "Deferred findings" and the todo `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md`.

View File

@@ -0,0 +1,114 @@
---
context: phase
phase: 19.7-generic-mcp-module
plan: 19.7-09
task: 0
total_tasks: 4
status: paused
last_updated: 2026-10-23T15:13:42.099Z
---
# Phase 19.7: paused before gap plan 19.7-09
## Critical Anti-Patterns
| Pattern | Description | Severity | Prevention Mechanism |
|---------|-------------|----------|---------------------|
| Tagging before full verification | The user will not accept a v0.5.0 that is bumped to v0.5.1 right after because verification found something. The plan 05 security review marked T-19.7-22 as verified, and a deep code review then found 3 critical issues. | blocking | Tag nothing until every gate below is green and its findings are fixed: the re-review of plans 06-09, the regression gate, msd-verifier, /msd-validate-phase and /msd-verify-work (UAT). Only then ask the user about v0.5.0. |
| Mid-phase framework tag | v0.5.0 is reserved for phase completion. The release order is decided: framework v0.5.0, then sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags that require it, then the app pointer bumps. | blocking | No tag, push or deploy step inside plan 09. The release happens only after phase verification, and only on the user's go-ahead. |
| Plan drift on versions | Plans written earlier named versions that were already taken (v0.1.9) or tags that were never cut ("plan-02 tag"). | advisory | Before dispatching an executor, check the current tags (`git tag`, `ls-remote`) and pass the corrections in the executor prompt. |
<current_state>
Phase 19.7 (Generic MCP module) is at 8 of 9 plans complete.
- Plans 01-05 are the original phase. 01-02 are released in framework v0.4.4. 03 released sm-user-plugin v0.1.10. 04 released sm-oauthserver-plugin v0.1.2 and sm-journal-plugin v0.2.3. 05 added the unit tests, the check-phase19.7.sh gate and the security review.
- A deep 3-lane code review (19.7-REVIEW.md: 3 critical, 17 warning, 20 info) led to gap-closure plans 06-09 (19.7-GAP-INPUTS.md). They also carry two sm-servers-app escalations: E-1, an organisation logo, and E-2, plugin-declared scopes for member usr_ tokens.
- Gap plans 06, 07 and 08 are executed and summarized. **19.7-09 is the next plan and has not started.** It covers tests for every fix and for E-1/E-2, the extended gate, the validation rows, and the corrected security docs.
</current_state>
<completed_work>
- 19.7-03: the user-plugin identity chain; sm-user-plugin v0.1.10 tagged and pushed; five apps bumped (local commits).
- Fast fix d96c726: cabana subject events now fire as `*pact.MCPSubjectChanged` after commit.
- 19.7-04: oauthserver v0.1.2 and Journal v0.2.3 tagged and pushed; vue-demo-app pushed; demo MCP wiring, nginx and DEPLOY.md.
- 19.7-05: tests, the gate, the security review, and 3 framework fixes (c49983d, 1111828, c29d228).
- Code review: 19.7-REVIEW.md (fef1cbb). Regression gate green at that point.
- Gap plans:
- 19.7-06: pier hardening, lane A (commits 2bf9dca..95f177b; SUMMARY 6ca9e17).
- 19.7-07: auth_time, the pact.HasFrontendTokenScopes seam, cabana and the admin SPA, lane B (c14238b..88559d5; SUMMARY d6c7fd5).
- 19.7-08: the plugins and demo, lane C, plus E-1 and E-2 (SUMMARY 1e5d9d4). Commits:
- user: 32f9259, 970719c, ee5c096, f20a8d3
- oauthserver: 671513d
- journal: b129902
- vue-demo-app: 0125e29
- sm-demo-app: 00f57bf, 3b569a1, 7731641
</completed_work>
<remaining_work>
1. Execute 19.7-09 (`/msd-execute-phase 19.7 --gaps-only`). Notes for its executor, from the 08 SUMMARY:
- write the named revocation tests (TestMCPVerifierRevocation, TestMCPGrantRevocation, TestAccessRevocation);
- write unit tests for linkCode.ts and OrganisationLogoFields;
- remove the now-unused `connectWatching` helper in the demo e2e test;
- record as deferred: `admin:token:revoke` fires no MCP event (from 07), B-IN-05, B-IN-06.
2. Fresh code review of the gap-closure changes (06-09) across all repos; fix its findings.
3. Regression gate:
- `go vet` and `go test ./...` with and without `-tags summercms_mcp`;
- examples/hello;
- check-phase19.6.1.sh and check-phase19.7.sh `--all`;
- check-admin-openapi.sh, check-admin-dist.sh, `summer docs:build --check`.
4. Phase-goal verification (msd-verifier → 19.7-VERIFICATION.md), then Nyquist validation (/msd-validate-phase 19.7), then UAT with the user (/msd-verify-work 19.7). UAT must include a real MCP client against the local demo; plan 04 skipped that check.
5. Decide on the deferred items in deferred-items.md that could block v0.5.0 (the user decides each one).
6. Only then, on the user's go-ahead:
- tag framework v0.5.0 and post the changelog (CLAUDE.md);
- tag the plugins requiring v0.5.0;
- bump the apps, including the sm-demo-app submodule pointers for user, oauthserver, journal and vue-demo-app;
- g15office sync: move tasks hmguritm and tqyvnzxx into milestone dcnv4mha and post the phase comment.
</remaining_work>
<decisions_made>
- v0.5.0 is tagged only after full verification and validation (user, 2026-10-23; saved in memory).
- Gap closure is 4 plans. 07 depends on 06 because of file overlap, so the waves run 1/2/3/4. The user approved this.
- Every critical and warning finding is fixed; info findings are fixed where cheap, otherwise deferred with a reason.
- E-2 uses a separate `pact.HasFrontendTokenScopes`, not cabana's HasTokenScopes (note .planning/notes/2026-10-23-frontend-token-scopes.md). `MintAPITokenFor(app, …)` is scoped to the app; the legacy MintAPIToken uses a process-wide fallback.
- E-1 adds the logo as a second attachment beside avatar, delivered as the helpers OrganisationLogoURL and OrganisationLogoFields. The plugin serves no organisation payload.
- A ban or password reset revokes MCP AND OIDC grants (user, 2026-10-23). A link change revokes backend-scoped MCP grants.
- `auth_time` is set at sign-in and copied unchanged by refresh. Sessions issued before the upgrade must sign in again before linking.
</decisions_made>
<blockers>
- None technical.
- **Everything since the last pushes is local and unpushed** (see Infrastructure State). If the next session runs on a different machine, these commits must be pushed first, with the user's go-ahead (CLAUDE.md: no push without go-ahead).
</blockers>
## Required Reading (in order)
1. `.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md`: gap scope, escalations, decisions, release order
2. `19.7-REVIEW.md`: the findings 06-09 fix
3. `19.7-06-SUMMARY.md`, `19.7-07-SUMMARY.md`, `19.7-08-SUMMARY.md`: what was done and the deviations
4. `19.7-09-PLAN.md`: the next plan
5. `deferred-items.md`
6. Memory: release-tag-only-at-phase-completion
## Infrastructure State
Last measured 2026-10-23T15:13Z. Nothing is pushed beyond the tags listed above.
| Repo | Branch @ HEAD | Ahead of origin | Uncommitted |
|---|---|---|---|
| summercms.go | master | 42 | `.planning/state.json` (another session's), `.msd/`, two `.gitkeep` |
| sm-user-plugin (sm-demo-app/plugins/golem15/user) | master @ f20a8d3 | 5 | clean |
| sm-oauthserver-plugin | master @ 671513d | 2 | `go.work`: tracked file, its user replace was pointed at ../sm-demo-app/plugins/golem15/user for local dev. Restore it or decide its fate before release |
| sm-journal-plugin | main @ b129902 | 2 | `go.work.sum` (untracked, not ours) |
| sm-demo-app | master @ 7731641 | 12 | `main.go` (another session's); submodule checkouts user/oauthserver/journal moved to local commits, and their pointers are bumped only at release |
| vue-demo-app | master @ 0125e29 | 1 | clean. Must be pushed before sm-demo-app 00f57bf |
| fonoteka.go | master @ 06bc9d0 | 35 | clean |
| sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app | master | 1 each (the plugin v0.1.10 bump) | unrelated untracked files |
Tools: the msd CLI is `node ~/.claude/msd-core/bin/msd-tools.cjs` (no msd_run on PATH). Docker tests run directly (socket available); fall back to `sudo -u golem` if the socket is denied.
<context>
Execution model: the orchestrator dispatches one msd-executor per plan, sequentially on the main tree (workflow.use_worktrees=false). It passes the drift corrections in the prompt and updates ROADMAP/STATE through msd-tools after each plan ("docs(phase-19.7): update tracking after gap wave N"). Plan 09 is autonomous, with no checkpoints. The user wants evidence-backed gates and dislikes surprise version bumps.
</context>
<next_action>
Start with: confirm the repos above are present at the listed HEADs on this machine (push or pull first if moving machines, with the user's go-ahead). Then run `/msd-execute-phase 19.7 --gaps-only`; it will pick up 19.7-09.
</next_action>

View File

@@ -0,0 +1,215 @@
---
phase: 19.7-generic-mcp-module
plan: 03
subsystem: user-plugin
tags: [sm-user-plugin, mcp, backend-link, frontend-permissions, guard-provider, personal-tokens, release, pointer-bumps]
requires:
- phase: 19.7-01
provides: AccountContributions and AdminActionExtensions seams, prompted record actions, pact MCP contracts (MCPGuard, MCPGuards, MCPLinkedBackendResolver, MCPPersonalTokenResolver, MCPSubjectChanged, HasFrontendPermissions)
- phase: 19.7-02
provides: modules/pier (catalog, guard providers, per-request identity, subject-event fan-out), released opt-in in framework v0.4.4
provides:
- sm-user-plugin v0.1.10 (git.golem15.com/golem15/sm-user-plugin), tagged and pushed, requiring SummerCMS v0.4.4
- verified frontend-to-backend account link (tables, one-shot hashed code, fresh-sign-in proof, classes.BackendLinks published as pact.MCPLinkedBackendResolver)
- Administrators-screen record actions link_frontend_account (prompted) and unlink_frontend_account behind golem15.users.manage_backend_links
- My account section frontend_account (link, unlink)
- classes.FrontendPermissionCatalog (seeded rows plus plugin codes; seeded row wins)
- classes.MCPUserGuard (guard "user", usr_ personal-token resolver), GET /_user/api/v1/token/me, MCP tool user_me
- pointer bumps to v0.1.10 + summercms v0.4.4 in sm-demo-app, fonoteka.go, sm-bm-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app (not pushed)
affects: [19.7-04 oauthserver verifier and Journal (consumes the link resolver and user guard), 19.7-05 unit tests and security review, framework v0.5.0 at phase completion (cabana subject-event fix first)]
actuals:
tokens: 26468
tasks: 5
commits: 6
plan_head_before: 3828a65481190acf0b450e5f880cf85a14d63ad8
plan_head_after: bfcfea4ed7d45374e9584557412b88797b051741
tech-stack:
added: []
patterns:
- "Subject events fire as *pact.MCPSubjectChanged (pointer) after commit through lagoon.AfterCommit"
- "Fresh-sign-in proof: confirm route behind jwt.auth re-reads the bearer and requires iat (seconds) >= the link request's created_at (seconds)"
- "Guard provider registers itself into the find-or-publish *pact.MCPGuards registry and doubles as pact.MCPPersonalTokenResolver"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/updates/202610220001_create_backend_links.go
- ../sm-demo-app/plugins/golem15/user/models/backend_link.go
- ../sm-demo-app/plugins/golem15/user/models/backend_link_request.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/frontend_permission_catalog.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/token_me.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/backend_links_admin.go
- ../sm-demo-app/plugins/golem15/user/mcp.go
- ../sm-demo-app/plugins/golem15/user/backend_link_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_admin_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_smoke_test.go
modified:
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/admin_permissions.go
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/privileged.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/usergroups_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-demo-app/plugins/golem15/user/go.mod
- ../sm-demo-app/plugins/golem15/user/go.sum
- "pinned-count tests: admin_harness_test.go, admin_registration_test.go, admin_users_test.go, password_routes_test.go, updates/organisations_test.go, updates/user_groups_test.go, updates/user_session_test.go"
- "five apps: go.mod, go.sum, plugins/golem15/user"
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "P-10: golem15_user_backend_links (1:1, both sides unique) and golem15_user_backend_link_requests (64-hex code stored as SHA-256, request_ttl default 10m, one-shot, bound to the creating admin)"
- "P-11: confirm behind jwt.auth, bearer re-read with bouncer.VerifyClaims; iat older than the request's created_at answers 409 stale_session"
- "P-12: admin set by exact, case-insensitive email of an active user; no search or list; golem15.users.manage_backend_links with no default role; linked_by recorded"
- "P-13: golem15.user.frontend_url added here (empty default falls back to app.url); link page is <frontend_url>/link-admin?code=<plain>"
- "P-14: MCPAllows uses PrincipalByID group grants and cabana.Allows per code with AND, the same as user.permission middleware"
- "P-15 (user, Task 4 checkpoint): release as v0.1.10 (v0.1.9 was taken by the public_routes release), bump all five applications"
- "Plugin subject events fire as pointers after commit; cabana's value-typed in-transaction firing is a framework bug recorded in deferred-items.md, to fix before v0.5.0"
patterns-established:
- "A plugin-side MCP guard provider: register into *pact.MCPGuards, answer MCPAllows with the same evaluation the route middleware runs, dispatch with an in-memory (ID 0) credential"
requirements-completed: [SC-4, D-01, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-15, D-16, D-17, D-18, D-23, D-26, D-34, D-35]
coverage:
- id: D1
description: "Self-service link: My account request, one-shot code, fresh sign-in confirm; replayed, expired and stale-session confirms refused; no email match or list on this path; unlink and delete remove the link"
requirement: "D-05"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/backend_link_smoke_test.go#TestBackendLinkSmoke"
status: pass
human_judgment: false
- id: D2
description: "Administrators-screen link and unlink behind golem15.users.manage_backend_links, linked_by recorded, events fired, taken account refused"
requirement: "D-34"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/backend_link_admin_smoke_test.go#TestBackendLinkAdminSmoke"
status: pass
human_judgment: false
- id: D3
description: "User guard: tools/list agrees with user.permission (group grants, AND across codes), every user.scope required, usr_ personal tokens resolved, user_me returns the payload"
requirement: "D-35"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/mcp_guard_smoke_test.go#TestUserMCPGuardSmoke"
status: pass
human_judgment: false
- id: D4
description: "Plugin frontend permission codes appear in both editors next to seeded rows; seeded row wins"
requirement: "D-09"
verification:
- kind: unit
ref: "../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_smoke_test.go#TestFrontendPermissionCatalogSmoke"
status: pass
human_judgment: false
- id: D5
description: "Additive release v0.1.10 on origin requiring summercms v0.4.4; five apps bumped one commit each, each builds and passes short tests"
requirement: "D-01"
verification:
- kind: other
ref: "git ls-remote --tags origin refs/tags/v0.1.10 (a2f8672); go build ./... && go test -count=1 -short ./... in each app"
status: pass
human_judgment: false
duration: about 27 min of commit activity (first task commit 10:03Z to the last app bump 10:28Z, 2026-10-23), plus the Task 4 checkpoint wait
completed: 2026-10-23
status: complete
---
# Phase 19.7 Plan 03: sm-user-plugin account link, user MCP guard and v0.1.10 Summary
**sm-user-plugin v0.1.10 adds the verified frontend-to-backend account link. An admin starts it on My account and a frontend user confirms it with a one-shot hashed code and a fresh sign-in. The Administrators screen can link and unlink accounts under `golem15.users.manage_backend_links`. The release also adds plugin-declared frontend permission codes, the `user` MCP guard provider with `usr_` personal-token resolution, and the `user_me` tool. It requires SummerCMS v0.4.4 and is pinned in all five applications.**
## Performance
- **Duration:** about 27 min of commit activity on 2026-10-23 (10:03Z to 10:28Z), split by the Task 4 release checkpoint
- **Completed:** 2026-10-23T10:28Z
- **Tasks:** 5 (Task 4 was the release-version checkpoint, answered "v0110": v0.1.10, all five apps)
- **Files:** 37 in the plugin (2,418 insertions, 61 deletions since v0.1.9), plus go.mod, go.sum and the submodule pointer in each of five apps
## Accomplishments
- **Task 1 (tracer, `7ac80c9`):** migration `202610220001_create_golem15_user_backend_links` creates the two tables. `classes.BackendLinks` provides `CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest` (row lock, `iat` check), `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf` and `LinkedBackendUser`, and is published both as `*classes.BackendLinks` and as `pact.MCPLinkedBackendResolver`. Routes `GET /_user/api/v1/backend-link/{code}` and `POST /_user/api/v1/backend-link/confirm`, the My account section `frontend_account`, `BackendLinkedEvent` and `BackendUnlinkedEvent`, and subject events after commit. Deleting or deactivating a frontend user removes its link.
- **Task 2 (`0f545f5`, fix `dbb3d33`):** `classes.FrontendPermissionCatalog` feeds both permission editors. `classes.MCPUserGuard` is registered in `*pact.MCPGuards` and published as `pact.MCPPersonalTokenResolver`. This task also added `GET /_user/api/v1/token/me` and the `user_me` tool, plus subject events on token revoke and on Users and User Groups saves and deletes.
- **Task 3 (`6f1cb1a`):** `golem15.users.manage_backend_links` (en and pl labels), and the record actions `link_frontend_account` (prompted, exact email) and `unlink_frontend_account` (danger) on cabana's Administrators controller.
- **Task 4:** release checkpoint. The user chose v0.1.10 and all five applications.
- **Task 5:** README (`59cc3bd`) and `chore: require SummerCMS v0.4.4` (`bfcfea4`). The full Docker suite passed (`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`; `TestBackendLinkSmoke`, `TestBackendLinkAdminSmoke`, `TestUserMCPGuardSmoke` and `TestFrontendPermissionCatalogSmoke` print `--- PASS`). Annotated tag `v0.1.10` was pushed with master (`git push origin master v0.1.10`, remote tag object a2f8672). Then one pointer-bump commit in each app.
- Additive check: `git diff 3828a65 HEAD -- '*.go' ':!*_test.go'` removes no exported func or type.
## Task Commits
Plugin (`git.golem15.com/golem15/sm-user-plugin`, master, pushed):
1. **Task 1: verified link tracer** - `7ac80c9` (feat)
2. **Task 2: guard provider, frontend permissions, user_me** - `0f545f5` (feat), fix `dbb3d33`
3. **Task 3: Administrators-screen actions** - `6f1cb1a` (feat)
4. **Task 4: release checkpoint** - no commit
5. **Task 5: README and framework pin** - `59cc3bd` (docs), `bfcfea4` (chore), tag `v0.1.10`
Applications (`chore: bump user to v0.1.10 and require summercms v0.4.4`, not pushed):
| App | Commit | From |
|---|---|---|
| sm-demo-app | `985af53` | user v0.1.9, summercms v0.4.3 |
| fonoteka.go | `06bc9d0` | user v0.1.0 in go.mod (submodule at v0.1.6), summercms v0.4.0 |
| sm-bm-app | `d180d35` | user v0.1.8, summercms v0.4.3 |
| sm-grzybyfunkcjonalne-app | `acbf68b` | user v0.1.8, summercms v0.4.3 |
| sm-summercmsio-app | `a1a661d` | user v0.1.8, summercms v0.4.3 |
Each app ran `go build ./... && go test -count=1 -short ./...` at the new pins, and all passed. `go mod tidy` added the go-sdk v1.8.0 transitive set as indirect requirements, matching framework v0.4.4's go.mod. `commits: 6` is measured in the plugin repo (`git rev-list --count 3828a65..bfcfea4`). The five app commits are in addition to that count.
## Deviations from Plan
### Auto-fixed Issues
1. **[Rule 3 - Blocking] backend_users FKs through a `DO $$` block.** The migration adds the foreign keys to `backend_users` only when `to_regclass('backend_users')` exists, so the plugin's own test databases (which do not carry cabana's tables) still migrate. (Task 1)
2. **[Rule 1 - Bug] Updated test counts that the new rows and routes changed.** Route counts went from 18 to 21 and from 15 to 18, and the rollback counts, migration history and permission lists in the existing tests were updated to match. (Tasks 1-3)
3. **[Rule 1 - Bug] Subject events fire as `*pact.MCPSubjectChanged` after commit.** pier listens for the pointer type, so the plugin fires a pointer through `lagoon.AfterCommit`. cabana's own value-typed, in-transaction firing never reaches pier. That is a framework bug, recorded in deferred-items.md and to be fixed before v0.5.0. (Task 1)
4. **[Rule 2 - Security] The confirm route uses `classes.SessionToken` and requires the token's subject to equal the `jwt.auth` principal,** in addition to the `iat` check. (Task 1)
5. **[Rule 1 - Bug] `iat` and `created_at` are compared truncated to seconds.** JWT `iat` has whole-second precision, so a sign-in in the same second as the request would otherwise count as stale. (Task 1)
6. **[Rule 2 - Security] `CreateLinkRequest` deletes the admin's earlier unused requests,** so only the latest code is valid. (Task 1)
7. **[Rule 2] The Users and UserGroups update hooks fire permissions events on every save.** They do not diff the permissions first, which is cheap and errs toward re-listing. (Task 2)
8. **Recorded gap: the `user:token:revoke` console command fires no subject event.** Only the HTTP revoke does. (Task 2)
9. **[Rule 1] A second link for an already linked admin answers 409 (the action's Applies check), not 422.** The 422 `already_linked` path is tested with a second admin targeting a frontend user who is already taken. (Task 3)
10. **[Rule 1 - Bug, `dbb3d33`] Group-delete subject events moved to `FormBeforeDelete`.** This keeps the existing `FormAfterDelete` declaration unchanged, so the additive gate stays clean. Member ids are read inside the delete's transaction, and the events fire after commit. (Task 2 fix)
11. **"Linked to <email>" is interpolated,** which produces a dev-only missing-translation warning for the composed string. (Task 1)
12. **New export `ErrFrontendUserUnavailable`,** returned when the target frontend user is missing or inactive. This is additive. (Task 3)
Drift from the plan text: the plan proposed v0.1.9 and "the plan-02 tag". v0.1.9 had already been released for the public_routes switch, and plan 02 cut no tag (the framework shipped pier opt-in in v0.4.4), so the release is v0.1.10 against v0.4.4.
## Deferred Issues
- **Framework bug, cabana backend subject events (open, blocks v0.5.0):** `modules/cabana/subject_events.go` fires `pact.MCPSubjectChanged` as a value inside the save's transaction. `modules/pier/events.go` listens for `*pact.MCPSubjectChanged`. Because `festival.Bus` keys listeners by type, pier never receives cabana's backend deletion, permission, role and revocation events. The entry is in `.planning/phases/19.7-generic-mcp-module/deferred-items.md`.
- The `user:token:revoke` console command fires no subject event (deviation 8). A follow-up can fire the event from the console path.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model (T-19.7-22..29). The new routes, the guard provider and the admin actions are the surfaces that model covers.
## Next Phase Readiness
Plan 04 can look up `*classes.BackendLinks`, `pact.MCPLinkedBackendResolver`, the `user` guard in `*pact.MCPGuards` and `pact.MCPPersonalTokenResolver` from any of the five applications at v0.1.10. The application bump commits are local and wait for the user's push and per-app deploy.
## Self-Check: PASSED
- Plugin files present (models, classes, controllers, migration, smoke tests). Commits 7ac80c9, 0f545f5, 6f1cb1a, dbb3d33, 59cc3bd and bfcfea4 are in the plugin repo, and the tag v0.1.10 is on origin.
- App commits 985af53, 06bc9d0, d180d35, acbf68b and a1a661d exist, and `git diff --quiet HEAD -- go.mod go.sum plugins/golem15/user` is clean in each app.

View File

@@ -0,0 +1,245 @@
---
phase: 19.7-generic-mcp-module
plan: 04
subsystem: oauthserver-journal-demo
status: complete
tags: [sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, mcp, oauth, consent, backend-scope, release, nginx]
requires:
- phase: 19.7-02
provides: modules/pier (MCP endpoint, guards, dispatch marker, subject-event fan-out) in framework v0.4.4
- phase: 19.7-03
provides: sm-user-plugin v0.1.10 (verified backend link, pact.MCPLinkedBackendResolver, user guard, user_me)
provides:
- sm-oauthserver-plugin v0.1.2 (tagged on 8bbec72, pushed): backend scope on the MCP server, classes.MCPTokenVerifier published as pact.MCPTokenVerifier, GET|POST /oauth/mcp/requests/{request} consent JSON with backend_admin, classes.FireSubjectChanged / FireSubjectChangedAfterCommit (revoked, deleted)
- sm-journal-plugin v0.2.3 (tagged on 4ab94da, pushed): seven journal_ tools and the journal_draft_post prompt (guard backend, golem15.journal.access_posts); optionalBackendPrincipal honours the dispatch marker
- vue-demo-app 21a22b6 (pushed): /connect consent page, /link-admin link-proof page, connect.* and linkAdmin.* (en, pl), dev proxy for the MCP paths
- sm-demo-app (local, not pushed): MCP endpoint at /mcp behind the summercms_mcp tag, TestMCPEndToEnd as frontend user and as linked admin, pins oauthserver v0.1.2 / journal v0.2.3 / vue-demo-app 21a22b6, nginx MCP locations, check-deploy MCP rules, DEPLOY.md "MCP endpoint"
affects: [19.7-05 unit tests, revoke matrix and security review, the demo deploy (needs the user's go-ahead), framework v0.5.0 at phase completion]
actuals:
tokens: 28539 # chars/4 over the added lines of all five repos' diffs
tasks: 5
commits: 11 # across repos, see commit_ledger; the SUMMARY commit is extra
commit_ledger:
sm-oauthserver-plugin: {before: d31f8e7, after: 8bbec72, count: 2}
sm-journal-plugin: {before: ffa2c8f, after: 4ab94da, count: 1}
vue-demo-app: {before: f397df2, after: 21a22b6, count: 1}
sm-demo-app: {before: 985af539b9c15019bbbe0fcc3b5dc74c76057774, after: c21ae9dc317f73863494f412a81889bf346d553a, count: 6}
summercms.go: {before: 29ba9a28cba13e802e13635ba7d3cae3c3a3d9e0, after: 9550fe0a9f566d943d515bc47928725feb986317, count: 1}
plan_head_before: 29ba9a28cba13e802e13635ba7d3cae3c3a3d9e0
plan_head_after: 9550fe0a9f566d943d515bc47928725feb986317
tech-stack:
added: []
patterns:
- "MCP access tokens (oas_at_) are verified by the plugin that minted them and published as pact.MCPTokenVerifier; the verifier refuses revoked, expired, non-mcp, inactive or revoked clients"
- "The backend scope is granted at consent only when the frontend user has a verified admin link; granted = submitted ∩ requested"
- "Subject events from grant revocation and the user cascade fire as *pact.MCPSubjectChanged after commit"
- "A WinterCMS-style optional principal reads the MCP dispatch marker instead of short-circuiting on a missing bearer"
- "Streamable HTTP behind nginx: exact location, proxy_buffering off, 1h read/send timeouts"
key-files:
created:
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/mcp_consent_api.go
- ../sm-oauthserver-plugin/mcp_verifier_smoke_test.go
- ../sm-oauthserver-plugin/mcp_consent_smoke_test.go
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/mcp_smoke_test.go
- ../sm-journal-plugin/controllers/api/optional_principal_test.go
- ../sm-demo-app/vue-demo-app/app/pages/connect.vue
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/config/mcp.yaml
- ../sm-demo-app/config/env/production/golem15.oauthserver.yaml
- ../sm-demo-app/mcp_e2e_test.go
- ../sm-demo-app/internal/demotest/mcp_tag_on.go
- ../sm-demo-app/internal/demotest/mcp_tag_off.go
modified:
- ../sm-oauthserver-plugin/servers.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/routes.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/controllers/api/auth.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/vue-demo-app/nuxt.config.ts
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/config/golem15.oauthserver.yaml
- ../sm-demo-app/scripts/build.sh
- ../sm-demo-app/scripts/dev.sh
- ../sm-demo-app/scripts/check-all.sh
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/boot_test.go
- ../sm-demo-app/realtime_test.go
- ../sm-demo-app/sociallogin_test.go
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/DEPLOY.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "Released oauthserver v0.1.2 and Journal v0.2.3 (tag-both); pushed vue-demo-app master; sm-demo-app stays local until the user's go-ahead"
- "The demo's resource indicator comes from app.url + mcp.path and the production overlay, not from .env; .env keys are documented as overrides only"
- "nginx opens exactly /mcp, the two MCP metadata paths and /oauth/mcp/; /oauth/authorize and OIDC discovery stay closed, enforced by check-deploy.sh"
requirements-completed: [SC-1, SC-3, SC-4, D-02, D-05, D-15, D-16, D-17, D-18, D-21, D-22, D-23, D-26, D-30, D-31, D-33, D-35, D-36]
duration: ~50min
completed: 2026-10-23
---
# Phase 19.7 Plan 04: oauthserver and Journal on the MCP endpoint, demo wiring Summary
**The demo's own OAuth server now issues `oas_at_` tokens that pier verifies, with a `backend` scope granted only through a verified admin link. Journal exposes seven post tools and a draft prompt to linked admins. The demo site has `/connect` and `/link-admin`. Plugin releases oauthserver v0.1.2 and Journal v0.2.3 are pushed; the demo pins them and documents nginx and the deploy for `/mcp`.**
## Performance
- **Duration:** about 50 minutes (12:27 to 13:20, 2026-10-23, including the Task 4 checkpoint wait)
- **Tasks:** 5 of 5 (Task 4 was the go-ahead checkpoint, answered tag-both)
- **Repositories touched:** 5 (sm-oauthserver-plugin, sm-journal-plugin, vue-demo-app, sm-demo-app, summercms.go planning)
## Accomplishments
- An MCP client signs in on the demo's own authorization server (DCR, PKCE, `/connect` consent) and calls `user_me` through `/mcp` with an `oas_at_` token (Task 1, `TestMCPEndToEnd`)
- The consent is JSON for the site's `/connect` page. It shows the linked admin and grants `backend` only with a link. Revoking a grant or deleting a user fires `revoked` or `deleted` subject events after commit (Task 2)
- Journal's `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the `journal_draft_post` prompt are listed only for a linked admin with `golem15.journal.access_posts` and a token carrying `backend`. A created draft is authored by the admin, and unlinking removes the tools (Task 3)
- Releases pushed: sm-oauthserver-plugin v0.1.2 (annotated, on 8bbec72), sm-journal-plugin v0.2.3 (annotated, on 4ab94da), vue-demo-app master at 21a22b6 (Task 5)
- The demo pins all three. It has nginx locations for `/mcp` (unbuffered, 1h timeouts), `/.well-known/oauth-protected-resource*`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/`, `check-deploy.sh` enforces exactly that allow-list, and DEPLOY.md has a new "MCP endpoint" section (Task 5)
## Task Commits
1. **Task 1: Tracer, an oas_at_ token runs user_me through /mcp in the tagged demo:** `116df7f` (oauthserver feat), `44cac13` (sm-demo-app feat)
2. **Task 2: MCP consent JSON, revoked/deleted events, /connect and /link-admin:** `8bbec72` (oauthserver feat), `21a22b6` (vue-demo-app feat)
3. **Task 3: Journal MCP tools and prompt, the optionalBackendPrincipal fix, e2e as the linked admin:** `4ab94da` (journal feat), `8668918` (sm-demo-app test)
4. **Task 4: Go-ahead checkpoint:** answered tag-both, no commit
5. **Task 5: Release, pin, nginx and deploy docs:**
- tags `v0.1.2` (sm-oauthserver-plugin, pushed with master) and `v0.2.3` (sm-journal-plugin, pushed with main); vue-demo-app master pushed
- `aec16c4` chore: bump oauthserver to v0.1.2 (sm-demo-app)
- `996586c` chore: bump journal to v0.2.3 (sm-demo-app)
- `7e76957` chore: bump vue-demo-app (MCP consent and link pages) (sm-demo-app)
- `c21ae9d` feat: nginx locations and DEPLOY section for the MCP endpoint (sm-demo-app)
Planning: `9550fe0` deferred items (framework), plus this SUMMARY's docs commit.
## Verification
- Plugins before tagging: `go vet ./...` and `go test -count=1 -short ./...` green in both; the Docker suites, the vue test/build and the additive checks against d31f8e7/ffa2c8f had passed at the checkpoint
- After each pin: `go build -tags summercms_mcp ./... && go test -count=1 -short ./...` green in sm-demo-app; `scripts/check-pins.sh` reports `ok (15 modules)` after the last pin
- Task 5's automated verify passes: both plugin HEADs `describe --exact-match` to v0.1.2 / v0.2.3, `check-deploy.sh` ok, tagged build and short tests green, and no uncommitted change to go.mod, go.sum or the three submodule paths
- `git ls-remote` on origin lists `refs/tags/v0.1.2`, `refs/tags/v0.2.3` and vue-demo-app `master` at 21a22b6
- A release-mode build (`GOWORK=off go build -tags summercms_mcp`) compiles, and `TestMCPEndToEnd` (tagged, Docker) passes on the pinned tree
- `check-deploy.sh` rejects a `/oauth/` location, a `/.well-known/` location and a `/mcp` without `proxy_buffering off` (each tried and reverted)
## Deviations from Plan
### Auto-fixed and adjusted
**1. [Rule 3 - Blocking] The tag is passed to go build in the build and dev scripts**
- **Found during:** Task 1
- **Issue:** `summer.yaml` `build.tags` covers `summer build` only. `scripts/build.sh` and `scripts/dev.sh` call `go build` directly and produced binaries that refuse `mcp.enabled: true`.
- **Fix:** `GO_TAGS=summercms_mcp` in build.sh, `-tags summercms_mcp` in dev.sh, and `check-all.sh` also runs the tagged tests
- **Commit:** 44cac13
**2. [Rule 3 - Blocking] Untagged test binaries boot with mcp.enabled off**
- **Found during:** Task 1
- **Issue:** a plain `go test ./...` would boot the app without the pier build and fail on `mcp.enabled: true`
- **Fix:** `internal/demotest/mcp_tag_off.go` / `mcp_tag_on.go` set `mcp.enabled` from the build tag, so the untagged suite stays green and the tagged suite exercises the endpoint
- **Commit:** 44cac13
**3. [Rule 1 - Bug, worked around] pier publishes `*pier.Pier` once per app, but some tests build the router twice**
- **Found during:** Task 1
- **Issue:** a second `surf.BuildRouter` on the same app fails with `backpack: duplicate provider for *pier.Pier`
- **Fix:** `TestRealtimeRoutes` and `socialServer` set `mcp.enabled: false`. Serving is unaffected because a binary builds its router once. The framework-side fix is recorded in deferred-items.md.
- **Commit:** 44cac13
**4. [Rule 3] The resource indicator comes from the base app.url plus the production overlay, not .env**
- **Found during:** Task 1 / Task 5
- **Issue:** the plan put `SUMMER_MCP__RESOURCE` and `SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE` in the production `.env`
- **Fix:** `mcp.resource` defaults to `app.url` + `mcp.path`. `config/golem15.oauthserver.yaml` uses `http://127.0.0.1:8097/mcp` and `config/env/production/golem15.oauthserver.yaml` uses `https://demo.summercms.io/mcp`, so rome needs no `.env` key. DEPLOY.md documents both keys as optional overrides that must stay equal.
- **Commits:** 44cac13, c21ae9d
**5. [Rule 2 - Missing validation] Consent client-name cap and body validation**
- **Found during:** Task 2
- **Fix:** the consent JSON caps the client name at 120 runes (DCR clients choose their own names), and a decision POST whose body is not JSON answers 400 `{"error":"invalid_request"}`
- **Commit:** 8bbec72
**6. [Rule 2 - Security] The verifier also refuses inactive clients**
- **Found during:** Task 1
- **Fix:** besides revoked, `MCPTokenVerifier` refuses a token whose client has `is_active` false
- **Commit:** 116df7f
**7. [Rule 1] The user cascade fires through AfterCommit, and its `deleted` event duplicates the user plugin's**
- **Found during:** Task 2
- **Issue:** sm-user-plugin v0.1.10 already fires `deleted` for the user guard. The oauthserver cascade fires it again (through `FireSubjectChangedAfterCommit`, so nothing fires on rollback).
- **Fix:** both are kept. The cascade's event follows its own purge of tokens, codes and consents (every purge path, not only the user plugin's deletion), and a second `deleted` for the same subject only repeats the session close in pier. Plan 05's revoke matrix may drop one.
- **Commit:** 8bbec72
**8. [Rule 2] /link-admin stale-session handling also covers 401**
- **Found during:** Task 2
- **Fix:** on `409 stale_session` or a `401`, the page calls `logout()`, shows `linkAdmin.signInAgain` and offers the sign-in that returns to the same `/link-admin?code=...`, so the fresh sign-in D-05 requires happens before confirming
- **Commit:** 21a22b6
**9. [Rule 3] Nuxt dev proxy paths**
- **Found during:** Task 2
- **Fix:** `nuxt.config.ts` adds `/mcp`, `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` and `/oauth/mcp/` to the dev proxy targets, so a local client can use the Nuxt origin and the consent redirect lands on the Nuxt `/connect` page. DEPLOY.md, "MCP endpoint", "Locally", gives the hand-off: `SUMMER_APP__URL=http://127.0.0.1:3020`, with both resources at `http://127.0.0.1:3020/mcp`.
- **Commits:** 21a22b6, c21ae9d
**10. [Rule 3 - Blocking] check-deploy.sh refused any /oauth location**
- **Found during:** Task 5
- **Issue:** the deploy check rejected every `/oauth` location. Its `/.well-known` rule sat after a `sys.exit` inside an `if` and never ran.
- **Fix:** the check now allows exactly `/oauth/mcp/`, the ACME webroot and the two MCP metadata paths, runs the `/.well-known` rule in the location loop, and requires `= /mcp` to proxy to Go with `proxy_buffering off` and `proxy_read_timeout 1h`
- **Commit:** c21ae9d
**11. [Rule 1 - Docs accuracy] DEPLOY.md does not promise self-service revocation**
- **Found during:** Task 5
- **Issue:** MCP grants write no consent row, so `GET /oauth/authorizations` does not list them and `DELETE /oauth/authorizations/{client_id}` answers 404 for an MCP client
- **Fix:** DEPLOY.md says unlinking removes the Journal tools at once, MCP grants are not yet in the authorized-apps list, and deleting the user ends every grant. The gap is in deferred-items.md for plan 05's revoke matrix.
- **Commit:** c21ae9d
**12. Pre-existing main.go diff in sm-demo-app (left alone)**
- sm-demo-app had an uncommitted `main.go` change from another session before this plan. Running `summer build` with the v0.4.4 CLI reproduces HEAD's `main.go`, so this plan has no regeneration to commit. The working-tree diff is not ours: it was never staged, and it is still uncommitted.
**13. sm-journal-plugin has an untracked `go.work.sum`**
- It is generated by the plugin's own `go.work` (which is gitignored) and is not part of any commit or tag. The tag sits on the clean commit 4ab94da.
### Not performed in this run
- **Task 5's human check** with a real MCP client against the local demo (sign-in on `/connect`, `user_me` only, linking through `/link-admin`, reconnecting with `backend`, the `journal_` tools, a draft post, unlinking) was not run, so no local servers were left running. `TestMCPEndToEnd` covers the same path automatically. The steps are in DEPLOY.md, "MCP endpoint", "Locally". It is recorded in deferred-items.md.
## Auth Gates
None.
## Deferred Issues
Recorded in `.planning/phases/19.7-generic-mcp-module/deferred-items.md`:
- sm-demo-app is eight commits ahead of origin. It is not pushed or deployed, and both wait for the user's go-ahead (deploy also installs the vhost and runs the MCP smoke)
- release builds pinning summercms v0.4.4 lack the cabana subject-event fix d96c726 until the demo requires the next framework tag
- Redis for MCP sessions if rome ever runs a second instance
- MCP grants not in the authorized-apps list (DELETE answers 404), carried from Task 2
- pier's once-per-app publish, carried from Task 1
- the real-client check above
- a retracted indirect `github.com/gabriel-vasile/mimetype` v1.4.15 (through lagoon and validator), which predates this plan
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: public-surface | ../sm-demo-app/deploy/nginx/demo.summercms.io.conf | `/mcp`, `/.well-known/oauth-*` and `/oauth/mcp/` become public on the next deploy (T-19.7-37: throttled in pier and oauthserver; nginx timeouts bounded at 1h; check-deploy pins the allow-list) |
## Next Phase Readiness
- Plan 05 (unit tests, revoke matrix, security review) can run against tagged plugins and the pinned demo
- Before the demo deploy: the user's go-ahead for pushing sm-demo-app and deploying, and ideally a framework tag carrying d96c726
## Self-Check: PASSED
- Files present: connect.vue, link-admin.vue, mcp.go (journal), mcp_verifier.go, mcp_consent_api.go, demo mcp_e2e_test.go, nginx conf, DEPLOY.md "### MCP endpoint"
- Commits present: 116df7f, 8bbec72 (oauthserver); 4ab94da (journal); 21a22b6 (vue-demo-app); 44cac13, 8668918, aec16c4, 996586c, 7e76957, c21ae9d (sm-demo-app); 9550fe0 (framework)
- Tags on origin: sm-oauthserver-plugin v0.1.2, sm-journal-plugin v0.2.3

View File

@@ -0,0 +1,310 @@
---
phase: 19.7-generic-mcp-module
plan: 05
subsystem: testing
status: complete
tags: [pier, mcp, gate, security-review, bouncer, cabana, surf, wristband, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vitest]
requires:
- phase: 19.7-01
provides: dispatch marker, compiled handler lookup, stream route kind, account contributions, prompted record actions, wristband scope allow-list
- phase: 19.7-02
provides: modules/pier (catalog, dispatch, identity, sessions, fan-out, stateless path, PRM)
- phase: 19.7-03
provides: sm-user-plugin v0.1.10 (account link, frontend permission catalog, user MCP guard)
- phase: 19.7-04
provides: sm-oauthserver-plugin v0.1.2 (verifier, consent JSON), sm-journal-plugin v0.2.3 (MCP tools), demo wiring and TestMCPEndToEnd
provides:
- scripts/check-phase19.7.sh, the fail-closed multi-repo phase gate (self-test, go, plugins, consumers, docker, admin, docs, marker), green in full on a Docker host
- modules/pier/helpers_test.go (fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, newApp, startInstance, twoInstances, sdkClient, fireSubjectChanged)
- named tests for every validation row in the framework, the three plugins and the demo
- 19.7-SECURITY.md (threats_open 0) and 19.7-SECURITY-REVIEW.md (T-19.7-01..41, T-19.7-SC)
- three framework fixes: cabana's backend middleware refuses a frontend marker, pier accepts tools/call without arguments, an ended pier session is never written back
- the filled 19.7-VALIDATION.md (nyquist_compliant true) and the two folded todos closed
affects: [phase 19.7 verification (/msd-verify-work), framework v0.5.0 at phase completion, optional patch tags of the three plugins]
actuals:
tokens: 62152
tasks: 4
commits: 9
plan_head_before: 905111ba7240828f2434bf313439d5c8af0c78d1
plan_head_after: 4730e37742223133033c3ff20a3cd0c527d77c53
commit_ledger:
summercms.go: {before: 905111b, after: 4730e37, count: 9}
sm-user-plugin (sm-demo-app/plugins/golem15/user): {before: bfcfea4, after: 72de822, count: 1}
sm-oauthserver-plugin: {before: 8bbec72, after: 2929c91, count: 1}
sm-journal-plugin: {before: 4ab94da, after: a9a9b80, count: 1}
sm-demo-app: {before: c21ae9d, after: 6fa2d56, count: 1}
tech-stack:
added: []
patterns:
- "Required-name gate entries as <package>:<TestName>, grouped per package so a package without a match can never pass as 'no tests to run'"
- "Postgres subtests are created only when not -short (no t.Skip), so the detector's skip refusal stays meaningful and the docker stage runs the same top-level name in full"
- "In-process dispatch marker as a test harness: account and user-guard routes are exercised without an admin or session row"
- "Session persistence rewrites, never creates (SET XX), so an ended session stays ended"
- "Pin check that tolerates a plugin submodule ahead of its pin only by *_test.go commits"
key-files:
created:
- scripts/check-phase19.7.sh
- modules/pier/helpers_test.go
- modules/pier/tools_list_test.go
- modules/pier/catalog_test.go
- modules/pier/dispatch_test.go
- modules/pier/guard_rules_test.go
- modules/pier/sessions_test.go
- modules/pier/fanout_test.go
- modules/pier/endpoint_guards_test.go
- modules/bouncer/dispatch_test.go
- modules/cabana/dispatch_marker_test.go
- modules/cabana/account_contributions_test.go
- modules/cabana/record_action_prompt_test.go
- modules/cabana/scope_limit_for_test.go
- modules/surf/streaming_test.go
- modules/surf/pier_mount_test.go
- modules/wristband/mcp_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_test.go
- ../sm-demo-app/plugins/golem15/user/frontend_permission_catalog_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_test.go
- ../sm-oauthserver-plugin/mcp_verifier_test.go
- ../sm-oauthserver-plugin/mcp_consent_test.go
- ../sm-journal-plugin/mcp_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
modified:
- modules/cabana/http.go
- modules/pier/dispatch.go
- modules/pier/session.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- docs/services/authentication.md
- modules/pier/tracer_smoke_test.go
- modules/pier/sessions_smoke_test.go
- modules/pier/identity_smoke_test.go
- modules/pier/export_test.go
- modules/cabana/administrators_harness_test.go
- modules/cabana/record_action_prompt_postgres_test.go
- modules/surf/streaming_smoke_test.go
- admin/tests/account/AccountContributions.test.ts
- admin/tests/form/RecordActionsPrompt.test.ts
- ../sm-demo-app/mcp_e2e_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
- .planning/todos/done/2026-10-17-plugin-registered-frontend-permissions.md
- .planning/todos/done/2026-10-17-frontend-backend-account-link.md
key-decisions:
- "P-21 implemented: gate stages self-test, go, plugins, consumers, docker, admin, docs, marker; docker last, NOT RUN only without a daemon, a failure with PHASE197_REQUIRE_DOCKER=1"
- "P-22 implemented: 19.7-SECURITY.md and 19.7-SECURITY-REVIEW.md in the 19.4 shape; threats_open 0"
- "P-23 verdict: superuser reach accepted as designed with audit and ceiling; mcp.refuse_superuser_backend not added, so no patch tag is needed for it (conditional follow-up in deferred-items)"
- "MCP grants without a consent row: accepted for this phase (AR-19.7-03); access ends through client revocation, user deletion or deactivation, expiry and the verifier; the e2e pins that the revoked event ends the session"
- "The user plugin's additive check uses both pre-phase bases (930b158 and v0.1.9); the user plugin runs with GOWORK=off, the OAuth server and blog plugins in their own workspaces"
patterns-established:
- "A phase's last plan turns the plans' smokes into named tests the gate requires by package, untagged and with the build tag"
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-20, D-21, D-22, D-25, D-26, D-27, D-31, D-36]
coverage:
- id: D1
description: "The fail-closed multi-repo gate passes in full on a Docker host and its detectors are proven on planted output"
requirement: "D-31"
verification:
- kind: other
ref: "bash scripts/check-phase19.7.sh --all (PASS self-test, go, plugins, consumers, admin, docs, marker, docker)"
status: pass
human_judgment: false
- id: D2
description: "Every framework validation row has its named test (pier, bouncer, cabana, surf, wristband, vitest)"
requirement: "SC-3"
verification:
- kind: unit
ref: "modules/pier/{tools_list,catalog,dispatch,guard_rules,sessions,fanout,endpoint_guards}_test.go"
status: pass
- kind: unit
ref: "modules/bouncer/dispatch_test.go#TestDispatchMarker, modules/cabana/dispatch_marker_test.go#TestDispatchMarker"
status: pass
- kind: unit
ref: "modules/surf/{streaming,pier_mount}_test.go, modules/wristband/mcp_scopes_test.go, modules/cabana/{account_contributions,record_action_prompt,scope_limit_for}_test.go"
status: pass
human_judgment: false
- id: D3
description: "Plugin and demo rows: link proof, frontend permission catalog, user guard, verifier, consent, Journal declaration, end-to-end revoke matrix"
requirement: "SC-4"
verification:
- kind: integration
ref: "sm-user-plugin TestBackendLink, TestFrontendPermissionCatalog, TestUserMCPGuard (Postgres)"
status: pass
- kind: integration
ref: "sm-oauthserver-plugin TestMCPTokenVerifier, TestMCPConsent (Postgres)"
status: pass
- kind: unit
ref: "sm-journal-plugin TestJournalMCP"
status: pass
- kind: e2e
ref: "sm-demo-app TestMCPEndToEnd (tagged, Postgres)"
status: pass
human_judgment: false
- id: D4
description: "Security review of T-19.7-01..41 and T-19.7-SC with evidence; superuser verdict recorded"
requirement: "D-22"
verification:
- kind: other
ref: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md, 19.7-SECURITY.md (threats_open 0)"
status: pass
human_judgment: false
duration: about 95 min
completed: 2026-10-23
---
# Phase 19.7 Plan 05: Unit tests, phase gate and security review Summary
**A fail-closed gate across six repositories (`scripts/check-phase19.7.sh`) now requires a named test for every row of the validation map, and it passes in full on a Docker host. The security review covers every threat of the phase. The tests and the review found three framework defects, each fixed in its own commit: cabana's `backend` middleware accepted a frontend dispatch marker, a pier session that had ended could be written back by a request still in flight, and pier rejected argument-less tool calls.**
## Performance
- **Duration:** about 95 minutes (2026-10-23, roughly 11:05Z to 12:40Z)
- **Tasks:** 4 of 4
- **Repositories touched:** 5 with commits (summercms.go, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app)
## Accomplishments
- **Task 1 (tracer):** `modules/pier/helpers_test.go` collects the plan 02 fixtures and adds configurable fakes and the two-instance miniredis harness. `TestToolsList` covers per-principal lists, the read ceiling, a tool with no subject, unlisted calls that never reach the route, and the union of tools when an identity has both a frontend and a backend subject. `TestDispatchMarker` exists in both bouncer and cabana; the cabana one has a Postgres part. The gate shipped at this point with its self-test, go and marker stages.
- **Task 2:** `TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions` (on Redis across two instances and on the memory store), `TestFanOut`, `TestStreamingRoute`, `TestPierMount` (both builds), `TestMCPScopes`, `TestAccountContributions` (with a Postgres transaction case and a check that the existing payloads are unchanged), `TestRecordActionPrompt` and `TestScopeLimitForMethods`, plus extended vitest files. The gate's admin stage went live.
- **Task 3:** `TestBackendLink`, `TestFrontendPermissionCatalog` and `TestUserMCPGuard` in the user plugin; `TestMCPTokenVerifier` and `TestMCPConsent` in the OAuth server plugin; `TestJournalMCP`, which compiles Journal's declaration through pier itself; and a revoke matrix in the demo's `TestMCPEndToEnd`. The gate gained its plugins (with the additive-export checks), consumers, docker and docs stages. The validation map is filled in (`nyquist_compliant: true`).
- **Task 4:** wrote `19.7-SECURITY-REVIEW.md` and `19.7-SECURITY.md` (`threats_open: 0`). The review added pins for the endpoint's outer layers in `TestEndpointGuards` and a check that undeclared argument keys are refused, and recorded the superuser verdict. The two folded todos moved to `todos/done/` with closing notes.
## Task Commits
summercms.go (master, not pushed):
1. **Task 1:** `c49983d` fix(cabana): frontend dispatch marker refused by the backend middleware; `3bbbe6c` test: gate (go, marker stages), pier helpers, TestToolsList, TestDispatchMarker
2. **Task 2:** `c29d228` fix(pier): tools/call without arguments; `1111828` fix(pier): ended session never written back; `f14eae9` test: framework tests for every validation row, admin stage
3. **Task 3:** `a94bdc1` test: gate stages plugins, consumers, docker, docs; `d621ef1` docs: validation map
4. **Task 4:** `0b8e027` test: security review pins; `4730e37` docs: security review and contract, deferred items, todos closed
Plugins and demo (local commits, **not pushed and not tagged**):
| Repo | Commit | Content |
|------|--------|---------|
| sm-user-plugin (checkout at `sm-demo-app/plugins/golem15/user`) | `72de822` | TestBackendLink, TestFrontendPermissionCatalog, TestUserMCPGuard |
| sm-oauthserver-plugin | `2929c91` | TestMCPTokenVerifier, TestMCPConsent |
| sm-journal-plugin | `a9a9b80` | TestJournalMCP |
| sm-demo-app | `6fa2d56` | TestMCPEndToEnd revoke matrix |
All four add tests only; none changes shipped code. Patch tags (sm-user-plugin v0.1.11, sm-oauthserver-plugin v0.1.3, sm-journal-plugin v0.2.4) are optional and wait for the user. The demo's `plugins/golem15/user` working tree sits one commit (72de822) ahead of its v0.1.10 pin. I did not commit that pointer change in sm-demo-app. The gate's pin check reports the drift and accepts it only because the commit touches `*_test.go` files alone.
`commits: 9` is measured in summercms.go (`git rev-list --count 905111b..4730e37`); the four plugin and demo commits come on top of that count.
## Gate output (`bash scripts/check-phase19.7.sh --all`, final run after Task 4)
```
PASS self-test
PASS go
PASS plugins
pins: plugins/golem15/user is ahead of its pin bfcfea4ed7d4 by test-only commits (release not needed for shipped code)
check-deploy: ok
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed
```
Exit 0. These also pass: `bash scripts/check-phase19.6.1.sh --all` (every stage PASS, docker included), and in the framework `go vet ./...` plus the full `go test -count=1 ./...` with Docker.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug, security] cabana's `backend` middleware honoured a frontend dispatch marker**
- **Found during:** Task 1 (cabana `TestDispatchMarker`, in-process admin API case)
- **Issue:** `bouncer.Registry.Middleware("backend")` honoured any marker that listed `backend`, even one whose principal had `Backend: false`. The admin API would then have treated a frontend user id as an administrator id. `ResolveBackend` already refused such a marker; the middleware did not.
- **Fix:** `refuseFrontendMarker` wraps the backend guard middleware and answers 401 for that case. The authentication docs page now states the rule.
- **Files modified:** modules/cabana/http.go, docs/services/authentication.md
- **Commit:** c49983d
**2. [Rule 1 - Bug] `tools/call` without arguments was refused**
- **Found during:** Task 2 (`TestDispatch`)
- **Issue:** a JSON `null` for arguments was validated against the object schema and refused, so an agent could not call a tool that has no required input unless it sent `{}`.
- **Fix:** an empty body or `null` now means `{}`.
- **Files modified:** modules/pier/dispatch.go
- **Commit:** c29d228
**3. [Rule 1 - Bug, security] an ended session could be written back**
- **Found during:** Task 2 (`TestFanOut`, failed about one run in a hundred)
- **Issue:** after a request finished, its session state was saved with a plain Put. If the session had ended while the request ran (DELETE, a revoked or deleted event, expiry), the save recreated the record and the next request rehydrated the ended session.
- **Fix:** an unexported `updater` (Redis `SET XX`, memory update-if-present). Persisting now only rewrites an existing record and closes the local copy when the record is gone. TestFanOut, TestSessions and both session smokes passed 300 times in a row afterwards.
- **Files modified:** modules/pier/session.go, store.go, store_memory.go, store_redis.go
- **Commit:** 1111828
**4. [Rule 3 - Blocking] pier helpers are in package `pier_test`, not `pier`**
- The plan named `package pier`, but the plan 02 smokes the helpers serve are in `pier_test`, which imports surf. Internal seams stay in `export_test.go` (package pier): `AdvanceStoreClock`, `StatelessServers`, `SetRecheck`.
**5. [Rule 3 - Blocking] cabana's `TestDispatchMarker` lives in `cabana_test`**
- The Postgres harness (`newScreensEnv`) is in the external package. The harness now keeps its `*backpack.App` (a one-field test change). For the same reason, `TestAccountContributions` is also in `cabana_test`.
**6. [Rule 3] Postgres subtests are created only when not `-short`**
- The gate's detector refuses skipped subtests. So `TestDispatchMarker`, `TestAccountContributions` and the other tests with a Postgres part return before creating that part under `-short`, and the docker stage runs the same top-level name in full.
**7. [Rule 2] `TestScopeLimitForMethods` (cabana) stands in for the backend guard's scoped principal case**
- The built-in backend provider needs a database (`cabana.BackendPrincipal`), so it cannot run under `-short`. `TestGuardRules` proves that pier asks a method-aware provider with each tool's method. `TestScopeLimitForMethods` proves that cabana's per-method filter is the one the backend guard uses.
**8. [Rule 3] `TestRecordActionPrompt`'s case for the `RecordAction` wrapper is pinned through the Postgres route test**
- The wrapper needs a database. `TestRecordActionPromptRun` gained a plain action that runs with an empty Value.
**9. [Rule 1 - test] the streaming smoke reset the mount hook to nil**
- In a build with the tag, that broke every later surf test. The smoke now restores the previous hook. This is a test-only change, committed in f14eae9.
**10. [Rule 3] the gate's consumers stage accepts a submodule that is ahead of its pin only by test commits**
- The demo's `check-pins.sh` correctly reports the user plugin checkout at 72de822 against the v0.1.10 pin. The gate accepts the drift only when the submodule descends from its pin and the diff touches nothing but `*_test.go` files; any other mismatch fails.
**11. [Rule 2] the gate also checks `account.go` and runs the demo's deploy check**
- The go stage refuses any change to `modules/cabana/account.go` since 51d088a (D-04: payloads unchanged). The consumers stage runs `check-deploy.sh` (T-19.7-37).
**Total deviations:** 3 production fixes (two security-relevant), 8 adjustments to test structure or the gate. No architectural change.
## Issues Encountered
- `cp` in this shell is interactive (`cp -i`); one backgrounded debug loop hung on its prompt and was killed. No repository content was affected.
- With `GOWORK=off` the user plugin and the demo's release build compile against summercms v0.4.4, which does not include this plan's framework fixes. The gate runs the demo through its workspace (the framework checkout); see deferred items.
## Known Stubs
None.
## Threat Flags
None beyond the phase threat model. This plan adds tests, the gate and three fixes that narrow existing surfaces.
## Deferred Items
Recorded in `deferred-items.md`:
- framework fixes c49983d, c29d228 and 1111828 are on master only; applications pinning v0.4.4 lack them until the next framework tag (v0.5.0 at phase completion)
- the four plugin and demo test commits are local and untagged (optional patch tags)
- a superuser refusal switch, conditional on a future superuser-only or destructive backend tool
- the decision on MCP grants that have no consent row (AR-19.7-03; the follow-up stays open)
## g15office (for the orchestrator's phase-completion step)
The two closed todos correspond to g15office tasks **`hmguritm`** (plugin-registered frontend permissions) and **`tqyvnzxx`** (frontend-backend account link). Both move into the phase milestone task **`dcnv4mha`** with a one-line comment. The phase-advance comment also belongs to that step (`.planning/G15OFFICE.md`). The executor did not touch g15office.
## User Setup Required
None. Push, tags and the framework v0.5.0 release wait for the user's go-ahead.
## Next Phase Readiness
`/msd-verify-work` can run the gate: `bash scripts/check-phase19.7.sh --all` on a Docker host. After that, phase completion: framework v0.5.0 (which carries plans 01-05 and this plan's fixes), then the demo bump and deploy.
---
*Phase: 19.7-generic-mcp-module*
*Completed: 2026-10-23*
## Self-Check: PASSED
All 25 listed created files exist on disk; the framework commits c49983d, 3bbbe6c, c29d228, 1111828, f14eae9, a94bdc1, d621ef1, 0b8e027 and 4730e37 and the plugin and demo commits 72de822, 2929c91, a9a9b80 and 6fa2d56 exist in their repositories; measured framework commits 905111b..4730e37 = 9.

View File

@@ -0,0 +1,407 @@
---
phase: 19.7-generic-mcp-module
plan: 06
type: execute
wave: 1
depends_on: []
gap_closure: true
files_modified:
- modules/pier/session.go
- modules/pier/pier.go
- modules/pier/audit.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- modules/pier/stateless.go
- modules/pier/identity.go
- modules/pier/dispatch.go
- modules/pier/catalog.go
- modules/pier/filter.go
- modules/pier/export_test.go
- modules/pier/reaper_smoke_test.go
- modules/pier/README.md
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cooler/cooler.go
- docs/services/mcp.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, D-10, D-11, D-13, D-17, D-19, D-20, D-25, D-26, D-27, D-28, D-32]
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 4
confidence: low
must_haves:
truths:
- "A stateful session whose store record has expired (idle past mcp.sessions.idle_ttl, or never DELETEd) is closed on every instance that holds a local copy: its SDK server session ends, it leaves the local map and the fan-out matching set, and an `expired` audit line is written (A-CR-01, A-IN-06, D-27)"
- "N concurrent initialize POSTs from one binding open at most mcp.sessions.max_per_binding sessions on the memory store and on Redis; a failed connect or initialize releases its reservation (A-WR-03, D-25)"
- "A slow guard or database check during one client's initialize, rehydration or stateless server build does not block other clients' requests on the instance (A-WR-07)"
- "When the same binding comes back with a wider identity (more scopes, a backend subject added), its stateful session and its stateless server gain the newly allowed tools and prompts on that request (A-WR-04, D-20, D-23)"
- "On the Redis store an instance subscribes to fan-out events at mount without waiting for a stateful request; a stateless subscriptions/listen stream is re-verified periodically and ends when its bearer stops resolving; an LRU-evicted stateless server's sessions are closed (A-WR-05, A-WR-06, D-26, D-32)"
- "A personal-token subject without a TokenID is refused at /mcp, so no two users share a binding (A-WR-09, D-19)"
- "pier never hands a route a path value the HTTP mux could not produce: empty, `.` or `..` segments are refused, a single segment never contains `/`, an omitted path field is refused at runtime and a path field that is not required in the schema fails boot (A-WR-01, A-WR-02, D-10, D-11)"
- "A guard dispatch failure answers the MCP client with a fixed text and is logged server-side; integer arguments above 2^53 keep their digits; the dispatched request carries the outer Host and forwarding headers; an embedded struct on a GET or DELETE tool fails boot; a provider answer naming the backend guard for a frontend tool is refused (A-WR-08, A-IN-04, A-IN-05, A-IN-07, A-IN-08, D-13, D-17)"
- "A prompt declared with Writes is hidden from an identity without write, as a non-read-only tool is (C-IN-01 framework part, D-20, D-28)"
artifacts:
- path: "modules/pier/session.go"
provides: "the session reaper and the lock-free connect path"
contains: "func (sr *sessionRouter) reap("
- path: "modules/pier/store.go"
provides: "the unexported reserver contract next to updater"
contains: "type reserver interface"
- path: "modules/pier/store_redis.go"
provides: "the atomic Lua reservation"
contains: "NewScript"
- path: "modules/pier/reaper_smoke_test.go"
provides: "the tracer's end-to-end smoke"
contains: "func TestPierReaperSmoke("
- path: "modules/pact/mcp.go"
provides: "MCPPrompt.Writes and the corrected personal-token resolver contract"
contains: "Writes"
key_links:
- from: "modules/pier/pier.go"
to: "modules/pier/session.go"
via: "Mount starts the reaper goroutine"
pattern: "reap("
- from: "modules/pier/session.go"
to: "modules/pier/store.go"
via: "serveNew reserves through the reserver interface instead of Count then Put"
pattern: "Reserve("
- from: "modules/pier/filter.go"
to: "modules/pact/mcp.go"
via: "allowsPrompt applies the read/write ceiling when decl.Writes is set"
pattern: "Writes"
---
<!-- planner-discipline-allow: TokenID == 0 -->
<objective>
Close lane A of the phase code review (`19.7-REVIEW.md`) in the framework's `pier` module: the critical session leak A-CR-01, the nine warnings A-WR-01..A-WR-09, and every lane A info finding (A-IN-01..A-IN-08), all of which are cheap. It also lands the framework part of C-IN-01 (`pact.MCPPrompt.Writes`), because that field lives next to the prompt filter. This is gap closure for phase 19.7 before framework v0.5.0 (`19.7-GAP-INPUTS.md`). The code review found no identity or guard bypass. These are lifecycle, concurrency and edge-handling defects, and the fixes keep D-25's rule that every request is authorized fresh.
**Planner decisions recorded here (P-24..P-28 continue plan 05's P-21..P-23):**
- **P-24 (reaper, A-CR-01):** a janitor goroutine started at `Mount` checks every local session against the store every `min(idle_ttl/2, 1m)`. It closes each local copy whose record is gone and logs `expired`. The store's TTL stays the single source of truth across instances: a session another instance keeps alive still has its record, so this instance keeps its copy. No new config key. Stop path: `backpack.App` has no shutdown hook today (its methods are `SetPlugins`, `HasPlugin`, `Publish` and `Lookup`). So `Mount` runs the reaper under a context whose cancel func is kept on the `Pier` (`p.stopReap`). In production it runs for the life of the process, like the store subscription and the user plugin's blacklist sweep. Tests stop it through the `StopReaper` seam, and when backpack gains a lifecycle hook, `Mount` registers `p.stopReap` there (one line, noted in the README). `export_test.go` gets the `ReapNow`, `LocalSessions` and `StopReaper` seams.
- **P-25 (atomic reservation, A-WR-03):** an unexported `reserver` interface (`Reserve`, `Release`) sits next to plan 05's `updater`. It follows the same pattern, so the exported `pier.Store` interface does not change. The memory store reserves under its mutex. The Redis store reserves with one Lua script (prune dead members, `SCARD`, `SADD` plus a placeholder `SET ... PX`); miniredis supports `EVAL`. A store that does not implement it falls back to `Count` then `Put`.
- **P-26 (widening, A-WR-04):** `Identity.fingerprint()` covers the authorization-relevant parts: sorted scopes, the frontend and backend subjects (guard, user, client, token), the ceiling and the token kind. A stateless hit or a stateful lookup whose fingerprint changed runs the same add/remove diff as a fan-out event.
- **P-27 (path values, A-WR-01, A-WR-02):** these refusals mirror what `net/http.ServeMux` guarantees over HTTP:
- every `path:` field must be listed as required in the reflected schema (boot error otherwise);
- at runtime an omitted path value, an empty, `.` or `..` segment, and a `/` inside a single-segment value are refused before dispatch.
- **P-28 (prompt ceiling, C-IN-01):** the new field `pact.MCPPrompt.Writes bool` (additive, zero value unchanged) puts a prompt under the same read/write ceiling as a non-read-only tool. Journal sets it in plan 08.
Purpose: the review's lane A blockers are gone before v0.5.0. Plan 07 builds its token-specific events on these files, plan 08 uses `MCPPrompt.Writes`, and plan 09 pins every fix with named tests.
Output: framework commits in `modules/pier`, `modules/pact` and `modules/cooler` with their README and docs pages, and one tracer smoke test.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-02-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-05-SUMMARY.md
@CLAUDE.md
@modules/pier/README.md
</context>
<project_rules>
- Unit tests are plan 09's job (project rule 2). This plan carries one tracer smoke, `TestPierReaperSmoke`. Every existing pier, surf and pact test, and the gate's go stage, stays green after each task: the earlier named tests (`TestSessions`, `TestFanOut`, `TestToolsList`, `TestDispatch`, `TestCatalog`, `TestEndpointGuards` and the smokes) are regression pins, not something to rewrite. A test that encodes the old, wrong behaviour (for example one relying on a shared `pat:user:0` binding, or on a non-required path field compiling) is changed in the same commit as the fix, and the SUMMARY names it.
- Documentation rule (CLAUDE.md): an exported API change, here `pact.MCPPrompt.Writes` and the resolver contract text, updates `modules/pact/README.md`, `modules/pier/README.md` and `docs/services/mcp.md` in the same commit. Every identifier in those files must exist (`summer docs:build --check`).
- No new dependency: `go.mod`, `go.sum` and `admin/package-lock.json` stay unchanged.
- Several sessions work in this repo: run `git status --short modules/pier modules/pact modules/cooler docs` before every task and leave other sessions' edits alone. Stage by explicit path, one logical change per commit, no co-author tags. Planning docs go in their own commit.
- Every commit is self-contained: at each commit the framework builds, `go vet ./...` is clean and the `-short` tests pass. No commit relies on a later task's commit. A task with several commits orders them so this holds.
- Release rule: no tag, push or deploy in this plan. Commits stay local on master.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: an abandoned stateful session is reaped end to end (store expiry → local close → audit line → gone from fan-out)</name>
<files>modules/pier/session.go, modules/pier/pier.go, modules/pier/audit.go, modules/pier/export_test.go, modules/pier/reaper_smoke_test.go, modules/pier/README.md</files>
<read_first>
- .planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md, sections A-CR-01, A-IN-01 and A-IN-06 (failure scenario and suggested janitor)
- modules/pier/session.go: `sessionRouter`, `localSession`, `serveNew`, `lookup`, `connect`, `persist`, `serveStream` (the GET re-check ticker), `closeLocal`, `matching`
- modules/pier/pier.go: `Mount` (where `listenEvents` and the store are set up) and `newStore`
- modules/pier/audit.go: `logSession` and the documented steps, including `expired`
- modules/pier/store_memory.go (lazy expiry, `now` clock) and store_redis.go (`Get` on an expired key)
- modules/pier/export_test.go (`AdvanceStoreClock`, `SetRecheck`) and modules/pier/helpers_test.go (`newApp`, `startInstance`, `sdkClient`, `fakeVerifier`)
</read_first>
<action>
Implement P-24 for A-CR-01.
In session.go:
- Add `func (sr *sessionRouter) reap(ctx context.Context, every time.Duration)`, a ticker loop that calls `reapOnce`.
- Add `func (sr *sessionRouter) reapOnce(ctx context.Context) int`. It copies the session ids from `sr.local` under `sr.mu`, releases the lock, then calls `sr.p.store.Get` for each id without holding any router lock. When the answer is `ok == false` and `err == nil`, it calls `closeLocal(id)` and `logSession(logger(), "expired", id, binding)`. It returns how many sessions it closed. A store error keeps the local copy, logs one warning per pass, and never closes on doubt.
- `closeLocal` already closes the SDK server session, which ends the `ss.Wait()` goroutine, and removes the id from `sr.local`. Confirm that `matching` reads only `sr.local`, so a reaped session drops out of fan-out.
In pier.go `Mount`, after `p.sessions` and `p.store` exist, create a cancellable context with `context.WithCancel(context.Background())`, store its cancel func in the new unexported `Pier` field `stopReap`, and start `go p.sessions.reap(ctx, reapInterval(cfg.IdleTTL))` with that context. The loop returns when the context is cancelled. `reapInterval` returns `min(idleTTL/2, time.Minute)`, floored at one second.
A-IN-01: in `serveStream`'s re-check ticker, after the bearer re-resolves, call `store.Get` for the session. A record that is gone (`ok == false`, no error) cancels the stream and calls `closeLocal`, so an `ended` event lost between instances no longer keeps the stream open.
A-IN-06:
- Emit `expired` from the reaper. Update the audit.go doc so every documented step is emitted somewhere.
- Log the tool calls `serveEphemeral` serves with the session label `ephemeral` instead of `stateless`. Change only that call site's label.
export_test.go gains three seams (the third is `func (p *Pier) StopReaper()`, which calls `p.stopReap`, so a test can end the goroutine; the smoke registers it with `t.Cleanup`):
- `func (p *Pier) ReapNow(ctx context.Context) int`, which calls `p.sessions.reapOnce`;
- `func (p *Pier) LocalSessions() int`, which counts `sr.local` under its lock.
New file `modules/pier/reaper_smoke_test.go` (package `pier_test`, built on helpers_test.go) holds `TestPierReaperSmoke` on the memory store:
1. Open a session with an SDK client (`sdkClient`) and confirm `LocalSessions() == 1`.
2. Advance the store clock past `mcp.sessions.idle_ttl` with `AdvanceStoreClock`, then call `ReapNow`; it returns 1 and `LocalSessions() == 0`.
3. The next request with the old `Mcp-Session-Id` answers 404.
4. A second session that stays active (touched by a request) survives a reap pass.
In modules/pier/README.md, add one paragraph under the session lifecycle: idle sessions are reaped at the store TTL, and `expired` joins the audit steps. No new configuration key.
</action>
<verify>
<automated>go vet ./modules/pier/... && go test -count=1 -short ./modules/pier/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^TestPierReaperSmoke$' -v ./modules/pier/ 2>&1 | grep -q -- '^--- PASS: TestPierReaperSmoke'</automated>
<fails_when>non-zero exit, "FAIL" in the output, or no `--- PASS: TestPierReaperSmoke` line (a SKIP counts as missing)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func (sr \*sessionRouter) reap(\|func (sr \*sessionRouter) reapOnce(" modules/pier/session.go` prints 2
- `grep -v '^\s*//' modules/pier/pier.go | grep -c "reap("` prints at least 1
- `grep -c '"expired"' modules/pier/session.go` prints at least 1 and `grep -c '"ephemeral"' modules/pier/session.go` prints at least 1
- `grep -c "func (p \*Pier) ReapNow(\|func (p \*Pier) LocalSessions(\|func (p \*Pier) StopReaper(" modules/pier/export_test.go` prints 3 and `grep -c "stopReap" modules/pier/pier.go` prints at least 1
- `grep -c "func TestPierReaperSmoke(" modules/pier/reaper_smoke_test.go` prints 1
- `grep -ci "reap\|expired" modules/pier/README.md` prints at least 2
</acceptance_criteria>
<done>An idle, abandoned stateful session is closed and forgotten at the store's TTL on every instance holding a copy, with an `expired` audit line; the smoke proves it end to end through a real SDK client.</done>
</task>
<task type="auto">
<name>Task 2: Atomic per-binding reservation and no router-wide lock across guard or database I/O</name>
<files>modules/pier/store.go, modules/pier/store_memory.go, modules/pier/store_redis.go, modules/pier/session.go, modules/pier/stateless.go</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-03 and A-WR-07
- modules/pier/store.go (`Store`, `updater`), store_memory.go (`Count`, `Put`, `Update`), store_redis.go (`Count` with `SMEMBERS` and `EXISTS`, the `MULTI` in `Put`, key layout under cooler's prefix, the `lazyStore` wrapper and how it forwards `updater`)
- modules/pier/session.go: `serveNew`, `lookup` (rehydration path calling `connect` under `sr.mu`), `connect`
- modules/pier/stateless.go: `getServer` (builds the server under `sp.mu`)
- the plan 05 commit 1111828 (`git show 1111828 --stat` and its diff of store.go): the `updater` pattern to mirror
</read_first>
<action>
**P-25 (A-WR-03).** In store.go, declare an unexported `reserver` interface:
- `Reserve(ctx context.Context, binding, id string, rec Record, max int, ttl time.Duration) (bool, error)`
- `Release(ctx context.Context, binding, id string) error`
Document it as the atomic form of `Count` then `Put`.
Implementations:
- **memoryStore:** `Reserve` runs under `m.mu`. It sweeps, counts the binding's live records, refuses at `max` and writes `rec` with the TTL. `Release` deletes the record.
- **redisStore:** `Reserve` is one `redis.NewScript` Lua script over the binding set and the record key. The script drops set members whose record key no longer exists, refuses when `SCARD >= max`, and otherwise runs `SADD` plus `SET <record> <json> PX <ttl>`. Keep the existing key layout so `Count`, `Get`, `Delete` and the TTLs stay compatible. `Release` does `SREM` plus `DEL`.
- **lazyStore:** forwards both methods, the same way it forwards `updater`.
In `serveNew`, replace the `Count` / `Put` pair:
- Reserve through the `reserver` when the store implements it. A refusal is the existing 429 body. A store error is the existing `storeUnavailable` answer.
- Otherwise fall back to the present `Count` then `Put` sequence.
- On a connect error, or an initialize that leaves `InitializeParams() == nil`, call `Release` so the slot frees.
**A-WR-07.**
- In `serveNew` and in `lookup`'s rehydration path, call `sr.connect(...)` with no router lock held. Then take `sr.mu`: if `sr.local[sid]` is already set by a concurrent request, close the newly built session and use the existing one; otherwise insert. Release the lock before serving.
- In stateless.go `getServer`, look up under `sp.mu`; on a miss, release the lock, build the server with `newServer`, take the lock again, and either adopt an entry another request inserted (dropping the new server) or insert and run the capacity eviction.
- `matching`, `closeLocal` and the fan-out paths keep their short critical sections.
- Never hold `sr.mu` or `sp.mu` while calling a guard, a resolver or the store.
</action>
<verify>
<automated>go vet ./modules/pier/... && go test -count=1 -short -race ./modules/pier/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... && go test -count=1 -short -run '^(TestSessions|TestFanOut|TestPierSessionsSmoke|TestPierReaperSmoke)$' -v ./modules/pier/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>non-zero exit, a data race report, "FAIL" in the output, or fewer than 4 top-level PASS lines</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type reserver interface" modules/pier/store.go` prints 1
- `grep -c "func (m \*memoryStore) Reserve(\|func (m \*memoryStore) Release(" modules/pier/store_memory.go` prints 2
- `grep -c "NewScript" modules/pier/store_redis.go` prints at least 1 and `grep -c ") Reserve(" modules/pier/store_redis.go` prints at least 2 (redisStore and the lazyStore forwarder, both in store_redis.go)
- `grep -v '^\s*//' modules/pier/session.go | grep -c "Reserve("` prints at least 1
- `go test -count=1 -short -race ./modules/pier/...` reports no race
</acceptance_criteria>
<done>The max_per_binding cap holds under concurrency on both stores; no router-wide pier lock is held across guard, resolver or store I/O; the existing session, fan-out and reaper tests stay green under -race.</done>
</task>
<task type="auto">
<name>Task 3: Identity widening on return, fan-out for stateless-only Redis instances, stateless listen re-check and LRU close, personal-token binding</name>
<reversibility rating="costly">The `pact.MCPPersonalTokenResolver` contract text now requires TokenID; a third-party resolver written against the old text must set it (published framework API, documented in pact README).</reversibility>
<files>modules/pier/session.go, modules/pier/stateless.go, modules/pier/pier.go, modules/pier/identity.go, modules/pact/mcp.go, modules/pact/README.md, modules/pier/README.md</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-04, A-WR-05, A-WR-06, A-WR-09, A-IN-03
- modules/pier/identity.go: `Identity`, `subjectFor`, `has`, `writes`, `resolvePersonalToken` (binding `pat:user:<TokenID>`)
- modules/pier/events.go: `handleEvent`, `recomputeSession`, `recomputeStateless` (the diff to reuse); `applyDiff` or its equivalent in session.go
- modules/pier/stateless.go: `getServer`, `evict`, `statelessEntry`, `ServeHTTP`; modules/pier/pier.go `route`, `newStore` (`lazyStore.open`), `Mount`
- modules/pier/session.go: `serveStream` (the periodic bearer re-check to mirror), `peekMethods`, `lookup`
- modules/pact/mcp.go: the `MCPPersonalTokenResolver` doc comment and `MCPSubject`
</read_first>
<action>
**P-26 (A-WR-04).** Add `func (id Identity) fingerprint() string` in identity.go. It encodes the sorted scopes, both subjects (guard, user, client, token, and their sorted scopes), `ceiling` and `TokenKind`.
- Store the fingerprint on `localSession` and on `statelessEntry` when each is built.
- In `lookup` (stateful) and on a `getServer` hit (stateless), compare the request identity's fingerprint. When it differs, under the session's or entry's own mutex (never `sr.mu` or `sp.mu`), recompute `p.catalog.allowed(ctx, id)`, apply the same add/remove diff `recomputeSession` and `recomputeStateless` use, and store the new identity and fingerprint.
- A narrower identity still relies on the receiving middleware filter, which stays unchanged.
**A-WR-05.** When the store is Redis, `Mount` starts a background warm-up. It calls the `lazyStore`'s open with backoff (1 s doubling to 30 s, logging each failure once per backoff step) until the store is open, and with it the `mcp:events` subscription.
- `route` also triggers the lazy open on the first request on any path, without blocking it. Stateless requests never wait for Redis.
- Stateful requests keep the 503 answer while the store is down.
**A-WR-06.**
- In `getServer`'s capacity eviction, close the evicted entry's SDK sessions the way `evict` does, outside `sp.mu`.
- In `statelessPath.ServeHTTP`, for a POST, read the body (it is already limited by `bodyLimit`) and restore it. When `peekMethods` contains `subscriptions/listen`, serve the request with a cancellable context and a ticker every `p.recheck` that re-resolves the request's bearer through `p.identities.resolve`. A resolve failure cancels the context, so the SDK ends the long-lived POST. This mirrors `serveStream`.
**A-WR-09 and A-IN-03.**
- `resolvePersonalToken` refuses a subject whose `TokenID == 0`, with the same `ErrUnauthorized` path as `UserID == 0`.
- Rewrite the `pact.MCPPersonalTokenResolver` doc in pact/mcp.go:
- one resolver per application (published through `app.Lookup`);
- false means "not this resolver's prefix" and pier answers unauthorized;
- `TokenID` must identify the token row, because it keys the session binding.
- Update the matching row in modules/pact/README.md and the personal-token paragraph in modules/pier/README.md in the same commit.
</action>
<verify>
<automated>go vet ./modules/pier/... ./modules/pact/... && go test -count=1 -short -race ./modules/pier/... ./modules/pact/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^(TestToolsList|TestFanOut|TestPierStatelessSmoke|TestPierFanOutSmoke)$' -v ./modules/pier/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
<fails_when>non-zero exit, a data race report, "FAIL" in the output, or fewer than 4 top-level PASS lines</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func (id Identity) fingerprint() string" modules/pier/identity.go` prints 1
- `grep -v '^\s*//' modules/pier/stateless.go | grep -c "fingerprint()"` prints at least 1 and `grep -v '^\s*//' modules/pier/session.go | grep -c "fingerprint()"` prints at least 1
- `grep -c "subscriptions/listen\|methodListen" modules/pier/stateless.go` prints at least 1
- `grep -v '^\s*//' modules/pier/identity.go | grep -c "TokenID == 0"` prints at least 1
- `grep -c "TokenID" modules/pact/mcp.go` prints at least 2 and the resolver doc no longer says another resolver is tried (`grep -c "next resolver is tried" modules/pact/mcp.go` prints 0)
</acceptance_criteria>
<done>A returning binding with more reach sees its new tools at once; a Redis instance serving only stateless clients receives fan-out; stateless listen streams end when their bearer stops resolving or their server is evicted; personal-token sessions can never share a binding; the resolver contract says what pier actually does.</done>
</task>
<task type="auto">
<name>Task 4: Dispatch and catalog hardening (path values, fixed error text, big integers, Host and forwarding headers, embedded fields, guard list), the prompt write ceiling, and cooler's doc comments</name>
<reversibility rating="costly">`pact.MCPPrompt.Writes` is a new field of a published capability (D-28 costly); its zero value keeps today's behaviour.</reversibility>
<files>modules/pier/dispatch.go, modules/pier/catalog.go, modules/pier/filter.go, modules/pact/mcp.go, modules/pact/README.md, modules/cooler/cooler.go, modules/pier/README.md, docs/services/mcp.md</files>
<read_first>
- 19.7-REVIEW.md, sections A-WR-01, A-WR-02, A-WR-08, A-IN-02, A-IN-04, A-IN-05, A-IN-07, A-IN-08 and lane C's C-IN-01
- modules/pier/dispatch.go: `callInfo` and its capture, `run` (argument split, `scalarString`, `queryStrings`, the guard dispatch error branch, `bouncer.WithDispatch`, the `httptest.NewRequestWithContext` build, `SetPathValue`)
- modules/pier/catalog.go: `compileTool` (segment ↔ `path:` field check, the GET/DELETE body-field refusal), `inputFields` (the anonymous-field skip), the reflected `schema.Required`
- modules/pier/filter.go `allowsPrompt`, `allowsTool`; modules/pact/mcp.go `MCPPrompt`, `MCPTool` (ReadOnly and Destructive overrides as the style model)
- modules/cooler/cooler.go lines 345-370 (`KeyPrefix` and `keyPrefix` doc comments)
- docs/services/mcp.md (prompts paragraph, tool argument rules) and modules/pier/README.md (catalog boot errors list)
</read_first>
<action>
**A-WR-01 (P-27).** In `run`, for every `argPath` value:
- split it on `/` and refuse any empty, `.` or `..` segment;
- for a single-segment field, refuse any `/` (as today);
- answer `pier: invalid arguments: "<name>" must not contain empty, . or .. segments` as a tool error before any dispatch.
**A-WR-02.**
- In `compileTool`, after the schema is reflected, fail boot with `<prefix>: path field "<name>" must be required (drop omitempty/omitzero)` when a `path:` field is missing from `schema.Required`.
- At runtime, refuse a missing path value before the request is built (defence in depth).
**A-WR-08.** Replace the branch that turns a `MCPDispatch` error into tool text built from the error's own message:
- log `pier: guard dispatch failed` server-side with tool, guard and error;
- return the fixed tool error `pier: the tool could not be run for this caller`;
- keep a specific message only for `pact.ErrMCPTokenInvalid` and the provider sentinel errors dispatch.go already distinguishes.
**A-IN-04.** Decode path and query scalars with `json.NewDecoder(bytes.NewReader(raw))` and `UseNumber()`, and render a `json.Number` as its literal string.
**A-IN-05.** Capture the outer `r.Host`, `X-Forwarded-Proto` and `X-Forwarded-Host` in `callInfo`. Set `req.Host` and copy both headers onto the dispatched request, next to X-Forwarded-For and X-Real-IP. The inner request still carries no Authorization or Cookie.
**A-IN-07.** In `compileTool`, fail boot when a GET or DELETE tool's input struct has an anonymous (embedded) field without a `path`, `query` or `json:"-"` tag. Name the field in the error.
**A-IN-08.** After `MCPDispatch`, refuse the call with a fixed tool error and a warning log when the answer contradicts the tool's guard:
- `disp.Guards` contains `BackendGuard` while the tool's guard is not backend;
- or `principal.Backend != (t.guardName == BackendGuard)`.
**C-IN-01 framework part (P-28).**
- Add `Writes bool` to `pact.MCPPrompt`, with a doc comment: the prompt directs write tools, so pier lists it only for an identity that may write.
- In filter.go `allowsPrompt`, return false when `p.decl.Writes && !id.writes()`, before the guard check.
**A-IN-02.** In modules/cooler/cooler.go, move the original `keyPrefix` comment block back directly above `func keyPrefix`, so `go doc` shows each function's own comment.
**Docs, same commit as the code:**
- modules/pact/README.md: the `pact.MCPPrompt` row names `Writes`.
- modules/pier/README.md: the boot-error list gains required path fields and embedded fields on GET/DELETE; the dispatch section gains the path-value rule, Host and forwarding headers, and the fixed guard-failure text.
- docs/services/mcp.md: the prompts paragraph explains `Writes`; the arguments section states the path rules.
</action>
<verify>
<automated>go vet ./... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/cooler/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go doc ./modules/cooler KeyPrefix | sed -n 4p | grep -q '^ KeyPrefix returns' && go doc -u ./modules/cooler keyPrefix | grep -A1 '^func keyPrefix' | grep -q 'keyPrefix returns' && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, or a gate line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "must be required" modules/pier/catalog.go` prints at least 1
- `grep -c "UseNumber()" modules/pier/dispatch.go` prints at least 1 and `grep -c "X-Forwarded-Proto\|X-Forwarded-Host" modules/pier/dispatch.go` prints at least 2
- `grep -c "the tool could not be run for this caller" modules/pier/dispatch.go` prints 1
- `grep -c "Writes" modules/pact/mcp.go` prints at least 1 and `grep -v '^\s*//' modules/pier/filter.go | grep -c "decl.Writes"` prints 1
- `go doc ./modules/cooler KeyPrefix | sed -n 4p` starts with ` KeyPrefix returns`, and `go doc -u ./modules/cooler keyPrefix` prints a `keyPrefix returns` comment directly under `func keyPrefix`
- `grep -c "Writes" modules/pact/README.md docs/services/mcp.md` prints at least 1 per file
- `git diff --quiet 2527498 -- go.mod go.sum admin/package-lock.json` (no dependency change since the gap-closure planning commit)
</acceptance_criteria>
<done>pier delivers only path values the mux could deliver, never leaks a provider or database error to an agent, keeps big integer ids exact, forwards Host and scheme, refuses embedded GET/DELETE fields and contradictory guard answers at the right time, and lets a plugin keep a write-directing prompt away from read-only grants; the gate's go, docs and marker stages are green.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Framework code (summercms.go, local commits on master):**
- `modules/pier`:
- session reaper (`reap`, `reapOnce`), `expired` and `ephemeral` audit labels;
- `reserver` with memory and Redis `Reserve`/`Release`;
- lock-free connect and stateless build;
- `Identity.fingerprint` and the widening diff;
- Redis warm-up at mount;
- stateless listen re-check and closing on LRU eviction;
- refusal of a personal-token subject without a TokenID;
- path-value and path-field rules, fixed guard-failure text, `UseNumber`, Host and forwarding headers, the embedded-field refusal, the guard-list check;
- the prompt write ceiling.
- `modules/pact`: `MCPPrompt.Writes`; the corrected `MCPPersonalTokenResolver` contract.
- `modules/cooler`: corrected doc comments.
**Docs:** `modules/pier/README.md`, `modules/pact/README.md`, `docs/services/mcp.md`.
**Test:** `modules/pier/reaper_smoke_test.go` (`TestPierReaperSmoke`), plus the export_test seams `ReapNow` and `LocalSessions`.
**Decisions:** P-24 to P-28 (in the objective above).
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP client to /mcp | any agent presents a bearer, a session id and JSON-RPC bodies (tool arguments become path, query and body of a plugin route) |
| pier to plugin routes (in-process dispatch) | pier hands a compiled route a request it built, with path values set directly (no mux cleaning) |
| instances to the shared session store | Redis holds session records, reservations and the fan-out channel |
| guard providers to pier | providers answer dispatch principals and guard lists that pier turns into the marker |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-42 | Denial of service | abandoned stateful sessions (local SDK server, transport, reader goroutines) | high | mitigate | P-24 reaper closes every local copy whose record expired, at min(idle_ttl/2, 1m); `expired` audit line; TestPierReaperSmoke here, TestSessionReaper in plan 09 |
| T-19.7-43 | Denial of service | concurrent initialize past mcp.sessions.max_per_binding | medium | mitigate | P-25 atomic `Reserve` (memory under lock, Redis Lua), `Release` on failure; plan 09 TestSessionReserve fires concurrent initializes |
| T-19.7-44 | Tampering | path values the mux never delivers (`..`, `.`, empty, `/` in one segment, an omitted optional path field) | high | mitigate | P-27: refusal before dispatch; path fields must be required at boot; plan 09 TestDispatchHardening and TestCatalogPathFields |
| T-19.7-45 | Information disclosure | raw provider or database errors (pgx address, database and user names) in tool results | medium | mitigate | fixed tool text, error logged server-side only |
| T-19.7-46 | Spoofing | personal-token sessions sharing the binding `pat:user:0` (cap exhaustion, cross-user DELETE) | medium | mitigate | `resolvePersonalToken` refuses TokenID 0; contract text requires TokenID |
| T-19.7-47 | Elevation of privilege | stateless listen streams and stateless-only Redis instances outliving a revocation | medium | mitigate | warm-up subscription at mount; periodic bearer re-check on subscriptions/listen; LRU eviction closes sessions |
| T-19.7-48 | Denial of service | router-wide locks held across guard and database I/O | medium | mitigate | connect and server build outside `sr.mu` and `sp.mu`, insert with re-check; -race clean |
| T-19.7-49 | Elevation of privilege | a provider naming the backend guard for a frontend tool's principal | low | mitigate | pier refuses a dispatch whose guard list or principal kind contradicts the tool's guard |
| T-19.7-50 | Information disclosure | a write-directing prompt listed for a read-only grant | low | mitigate | `MCPPrompt.Writes` under the read/write ceiling (P-28) |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package is added: go.mod, go.sum and admin/package-lock.json unchanged (acceptance criterion of Task 4) |
</threat_model>
<verification>
- `go vet ./... && go test -count=1 -short ./...` in the framework, plus `go test -count=1 ./modules/pier/...` on a Docker host (no change expected; pier has no Postgres suite)
- `go test -count=1 -short -race ./modules/pier/...` is clean
- `bash scripts/check-phase19.7.sh --go`, `--docs` and `--marker` print PASS
- `cd examples/hello && go test ./...` stays green (the separate workspace module)
</verification>
<success_criteria>
- A-CR-01 and A-WR-01..A-WR-09 are fixed, each in a commit whose subject starts with `fix(pier)` (or `fix(pact)` / `docs(cooler)` for the contract and doc-only parts); A-IN-01..A-IN-08 are fixed
- `pact.MCPPrompt.Writes` exists with docs, ready for Journal in plan 08
- No exported pier, pact or cooler identifier is removed or changes signature (the gate's docs and marker stages and `go vet` stay green; fonoteka.go and sm-bm-app still build against the local framework in plan 09's consumers stage)
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md` when done, with every commit sha mapped to its finding ids. Commit the SUMMARY separately as a planning doc.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order: framework v0.5.0, then the plugin tags that require it, then the application bumps.
</output>

View File

@@ -0,0 +1,189 @@
---
phase: 19.7-generic-mcp-module
plan: 06
subsystem: api
status: complete
gap_closure: true
tags: [pier, mcp, sessions, redis, lua, concurrency, dispatch, pact, cooler, gap-closure]
requires:
- phase: 19.7-02
provides: modules/pier (catalog, dispatch, identity, sessions, fan-out, stateless path)
- phase: 19.7-05
provides: the updater pattern (1111828), helpers_test.go fakes, scripts/check-phase19.7.sh
provides:
- session reaper (reap, reapOnce) started at Mount, `expired` and `ephemeral` audit labels, GET stream ends when the record is gone
- unexported reserver (Reserve, Release) on the memory and Redis stores (Lua script), used by serveNew
- lock-free server build in serveNew, lookup rehydration and stateless getServer
- Identity.fingerprint and re-filtering of a cached server when a binding returns with a different identity
- Redis store warm-up at Mount plus a non-blocking kick on every request
- stateless subscriptions/listen bearer re-check and session close on LRU eviction
- refusal of a personal-token subject without TokenID; corrected pact.MCPPersonalTokenResolver contract
- path-value and path-field rules, fixed guard-failure text, UseNumber, Host and forwarding headers, embedded-field refusal, guard-list check
- pact.MCPPrompt.Writes under the read/write ceiling
- cooler KeyPrefix / keyPrefix doc comments separated
affects: [19.7-07 (token-specific events build on session.go/stateless.go), 19.7-08 (Journal sets MCPPrompt.Writes), 19.7-09 (named tests pin every fix), framework v0.5.0]
actuals:
tokens: 20336
tasks: 4
commits: 6
plan_head_before: d18d8f0dc206392726e225c2f5cc1340d14463c1
plan_head_after: 95f177b1e0dbc27483d4ece9dc88751cb226f2fd
tech-stack:
added: []
patterns:
- "Unexported capability interface next to updater (reserver), checked with a type assertion, so the exported pier.Store does not change"
- "Build outside the router lock, then insert-or-adopt under it"
- "Identity fingerprint decides when a cached per-identity server is re-filtered"
key-files:
created:
- modules/pier/reaper_smoke_test.go
modified:
- modules/pier/session.go
- modules/pier/pier.go
- modules/pier/audit.go
- modules/pier/store.go
- modules/pier/store_memory.go
- modules/pier/store_redis.go
- modules/pier/stateless.go
- modules/pier/identity.go
- modules/pier/events.go
- modules/pier/dispatch.go
- modules/pier/catalog.go
- modules/pier/filter.go
- modules/pier/backend_guard.go
- modules/pier/export_test.go
- modules/pier/README.md
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cooler/cooler.go
- docs/services/mcp.md
key-decisions:
- "P-24: a reaper goroutine started at Mount closes every local session whose store record is gone every min(idle_ttl/2, 1m) (floor 1 s); the store TTL stays the single source of truth across instances; stopped through Pier.stopReap (no backpack shutdown hook yet)"
- "P-25: atomic per-binding reservation through an unexported reserver interface; Redis uses one Lua script (prune, count, SET PX, SADD, PEXPIRE) on the existing key layout"
- "P-26: Identity.fingerprint (sorted scopes, both subjects with guard/user/client/token/scopes, ceiling, token kind); a changed fingerprint re-runs the event add/remove diff under the session's or entry's own mutex, with context.WithoutCancel so a client hang-up cannot leave the set filtered by cancelled guard checks"
- "P-27: path fields must be required at boot; at runtime a missing value, an empty, . or .. segment, or / in a single segment is a tool error before dispatch"
- "P-28: pact.MCPPrompt.Writes (additive) puts a prompt under the read/write ceiling"
- "Guard dispatch errors answer the fixed text `pier: the tool could not be run for this caller`; only pact.ErrMCPTokenInvalid and a new errSubjectUnavailable sentinel (backend user unknown or deactivated) keep their own fixed texts"
patterns-established:
- "Background lifecycle goroutines of pier (reaper, Redis warm-up) share the context whose cancel func is Pier.stopReap"
requirements-completed: [SC-1, SC-2, SC-3, D-10, D-11, D-13, D-17, D-19, D-20, D-25, D-26, D-27, D-28, D-32]
duration: 23min
completed: 2026-10-23
---
# Phase 19.7 Plan 06: Lane A gap closure in pier Summary
This plan closes lane A of the code review in the framework's MCP module. Abandoned stateful sessions are now reaped at the store TTL. The per-binding cap is reserved atomically (memory store under its lock, Redis through a Lua script). No router-wide lock is held across guard or database I/O. A binding that returns with a wider identity is re-filtered on that request. Redis instances subscribe to fan-out when they mount. Stateless listen streams are re-verified, and an evicted stateless server closes its streams. Personal-token subjects must carry a TokenID. Dispatch only hands routes path values the HTTP mux could produce, and guard failures answer with fixed text. Prompts gain a `Writes` ceiling.
## Performance
- **Duration:** about 23 min
- **Completed:** 2026-10-23
- **Tasks:** 4 of 4
- **Files modified:** 20 (1 created)
## Commits and findings
| Commit | Task | Findings |
|--------|------|----------|
| 2bf9dca `fix(pier): reap stateful sessions whose store record expired` | 1 (tracer) | A-CR-01, A-IN-01, A-IN-06 |
| c1c0c77 `fix(pier): reserve session slots atomically and build servers outside router locks` | 2 | A-WR-03, A-WR-07 |
| 2404572 `fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID` | 3 | A-WR-04, A-WR-05, A-WR-06, A-WR-09, A-IN-03 |
| 104db99 `fix(pier): refuse path values the mux never delivers and harden dispatch` | 4 | A-WR-01, A-WR-02, A-WR-08, A-IN-04, A-IN-05, A-IN-07, A-IN-08 |
| 211cec1 `fix(pact): add MCPPrompt.Writes so a write-directing prompt sits under the read/write ceiling` | 4 | C-IN-01 (framework part) |
| 95f177b `docs(cooler): give KeyPrefix and keyPrefix their own doc comments` | 4 | A-IN-02 |
All lane A findings are covered: A-CR-01, A-WR-01..09 and A-IN-01..08.
## Accomplishments
- **Reaper (Task 1).**
- `sessionRouter.reap` and `reapOnce` run from `Mount` and close each local copy whose record has expired, logging `expired`.
- A store error keeps the copy and is logged once per pass.
- `serveStream`'s re-check ends the stream and closes the local copy when the record is gone.
- Ephemeral tool calls log the session as `ephemeral`.
- `TestPierReaperSmoke` proves the flow with a real SDK client. New seams: `ReapNow`, `LocalSessions`, `StopReaper`.
- **Reservation and locks (Task 2).**
- `reserver` with memory and Redis `Reserve`/`Release`, plus `lazyStore` forwarders.
- `serveNew` reserves first, then connects, and releases the slot on a connect or initialize failure.
- `serveNew`, `lookup` rehydration and the stateless `getServer` build the server with no `sr.mu`/`sp.mu` held, then insert or adopt.
- **Identity and lifecycle (Task 3).**
- `fingerprint()` is stored on `localSession` and `statelessEntry`; the `widen` helper re-filters on a change.
- `lazyStore.warm` (backoff 1 s to 30 s) starts at Mount, and `kick()` runs from `route`.
- The stateless `ServeHTTP` re-checks the bearer every `recheck` for `subscriptions/listen` POSTs.
- LRU eviction closes the evicted server's sessions.
- `resolvePersonalToken` refuses `TokenID == 0`, and the resolver contract text is rewritten.
- **Dispatch hardening and prompt ceiling (Task 4).** Covers every item listed in the commit table, and `allowsPrompt` honours `decl.Writes`.
## Verification
- `go vet ./...` and `go vet -tags summercms_mcp ./...` are clean.
- `go test -count=1 -short ./...` is green, and so is `-race` on `./modules/pier/...`.
- `go test -count=1 ./modules/pier/... ./modules/surf/...` passes with Docker available.
- `cd examples/hello && go test ./...` is green.
- `scripts/check-phase19.7.sh --go`, `--docs` and `--marker` all PASS.
- `summer docs:build --check` reports no problems.
- `go.mod`, `go.sum` and `admin/package-lock.json` are unchanged since 2527498.
- Commit 104db99 was checked on its own in a temporary detached worktree (vet, short tests, docs check), because Task 4 was split into three commits.
- Ad-hoc checks were run and then deleted, since plan 09 owns the named tests:
- 30 concurrent initializes with `max_per_binding: 2` gave exactly 2 OK and 28 429 on both the memory store and miniredis.
- A read grant followed by a read+write grant on the same binding gained the write tool on both the stateful and the stateless path.
- `9007199254740993` survived as a path value and as a query value.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing critical] Sentinel for the backend guard's "unknown or deactivated" answer**
- **Found during:** Task 4 (A-WR-08)
- **Issue:** The plan says to keep specific messages for "the provider sentinel errors dispatch.go already distinguishes", but dispatch.go distinguished none. The backend guard's "unknown or deactivated" answer was a plain `fmt.Errorf`.
- **Fix:** Added `errSubjectUnavailable` in `modules/pier/backend_guard.go`, which the backend guard now wraps. Dispatch maps it, and `pact.ErrMCPTokenInvalid`, to fixed texts. Every other error gets the generic fixed text and is logged server-side.
- **Files modified:** modules/pier/backend_guard.go (not in the plan's file list)
- **Commit:** 104db99
**2. [Rule 2 - Missing critical] Widening uses a non-cancellable context**
- **Found during:** Task 3 (A-WR-04)
- **Issue:** `catalog.allowed` hides an item on any guard error. If the request context were cancelled mid-recompute, the server would be re-filtered with tools missing and the new fingerprint stored, so the narrower set would stick.
- **Fix:** `widen` calls `allowed` with `context.WithoutCancel(ctx)`.
- **Files modified:** modules/pier/events.go
- **Commit:** 2404572
**3. [Rule 3 - Blocking] The ephemeral label survives `sessionPrefix` and the SDK session id**
- **Found during:** Task 1 (A-IN-06)
- **Issue:** An ephemeral call's SDK session id is `""`, the same as a stateless call. A plain label would also have been cut to 8 characters by `sessionPrefix`.
- **Fix:**
- `serveEphemeral` marks the per-request token info's `callInfo.session` as `ephemeral`.
- `toolHandler` overrides `info.session` only with a non-empty SDK session id.
- `sessionPrefix` passes the `ephemeral` label through untruncated.
- **Files modified:** modules/pier/session.go, modules/pier/audit.go
- **Commit:** 2bf9dca
**4. [Docs rule] Doc updates outside the listed tasks' file sets**
- In Task 3, `docs/services/mcp.md` gained the resolver/TokenID sentence plus one sentence each on the reaper and on widening. The CLAUDE.md rule requires docs to change in the same commit as an exported contract change.
- Task 4 was split into three commits (pier hardening, pact `Writes`, cooler docs) so each is one logical change and carries the commit-type prefix the success criteria name.
### Tests changed for old behaviour
None. No existing test relied on the old behaviour: no fixture uses an optional path field, a shared `pat:user:0` binding, an embedded GET/DELETE field or raw dispatch error text. Every regression pin passed unchanged.
## Notes for later plans
- Behaviour change for consumers: Mount now starts two background goroutines (the reaper, and for the Redis store a warm-up that dials `redis.url` with a backoff). `route:list` and tests on the Redis store therefore dial in the background. The pier README and Mount's doc comment say so.
- Consumer impact of the new boot errors: no sibling repository (fonoteka.go, sm-*-app, sm-journal-plugin, the user plugin copies) declares a path field with `omitempty`/`omitzero` or an embedded struct on a GET/DELETE input. Plan 09's consumers stage confirms this by building them.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: modules/pier/reaper_smoke_test.go
- FOUND: 2bf9dca, c1c0c77, 2404572, 104db99, 211cec1, 95f177b

View File

@@ -0,0 +1,522 @@
---
phase: 19.7-generic-mcp-module
plan: 07
type: execute
wave: 2
depends_on: ["19.7-06"]
gap_closure: true
files_modified:
- modules/bouncer/mint.go
- modules/bouncer/refresh.go
- modules/bouncer/jwt.go
- modules/bouncer/auth_time_smoke_test.go
- modules/bouncer/README.md
- docs/services/authentication.md
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/pact/README.md
- modules/cabana/token_scopes.go
- docs/plugins/registration.md
- docs/backend/users-and-permissions.md
- .planning/notes/2026-10-23-frontend-token-scopes.md
- modules/pier/store.go
- modules/pier/events.go
- modules/pier/session.go
- modules/pier/stateless.go
- modules/pier/pier.go
- modules/pier/host.go
- modules/pier/README.md
- modules/surf/pier_on.go
- modules/towel/context.go
- modules/towel/README.md
- modules/cabana/account_tokens.go
- modules/cabana/subject_events.go
- modules/cabana/token_resolver.go
- modules/cabana/account_contributions.go
- modules/cabana/actions.go
- modules/cabana/admin_openapi.go
- modules/cabana/README.md
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- docs/services/mcp.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/app/hotkeys.ts
- admin/src/components/account/AccountContributions.vue
- admin/tests/fixtures/lang.json
- modules/boardwalk/dist
autonomous: true
requirements: [SC-4, D-04, D-05, D-17, D-19, D-26, D-27, D-34, E-2]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 5
confidence: low
must_haves:
truths:
- "A frontend or backend token minted at sign-in carries an auth_time claim; POST /refresh (any audience) copies it unchanged into the rotated token, and a token that had none is refreshed without one; plugins read it through bouncer.VerifyAuthTime, which applies the same signature, audience and expiry checks as VerifyClaims (C-CR-02 framework part, D-05)"
- "A plugin declares member (frontend) personal-token scopes through pact.HasFrontendTokenScopes, a separate capability that cabana's admin token catalog never reads; pact.ValidTokenScopeName is the one name rule both catalogs use, and cabana's g15_ catalog behaves exactly as before (E-2 framework seam, decision P-30)"
- "Revoking one admin personal token ends only the MCP sessions opened with that token; an event naming a client ends only that client's OAuth sessions; an event naming neither ends every session of the subject as before (B-WR-04, D-26, D-27)"
- "A failed g15_ token attempt at /mcp is logged with the client address, and a resolver plugin can read the address from the context (B-IN-04)"
- "An account contribution action can only hand the admin an http(s) or same-origin relative URL; the server drops anything else and the SPA refuses to render or open it (B-WR-03)"
- "One contributing plugin's Status or Applies error no longer removes the other My account sections: the failing section is sent with error true, and a failed GET shows a toast (B-WR-06)"
- "A typed character or Esc inside a plugin field type's shadow DOM never triggers a plain-key shortcut, and Ctrl/Cmd+S or Ctrl/Cmd+Enter commits a pending taglist draft before saving (B-WR-01, B-WR-02)"
- "The published admin API contract says a prompted record action accepts payload, and admin.json, schema.d.ts and the committed admin dist match their sources (B-WR-05)"
artifacts:
- path: "modules/bouncer/mint.go"
provides: "auth_time stamped at sign-in"
contains: "ClaimAuthTime"
- path: "modules/bouncer/jwt.go"
provides: "the plugin-facing accessor"
contains: "func VerifyAuthTime("
- path: "modules/pact/capabilities.go"
provides: "the member token scope capability and the shared name rule"
contains: "type HasFrontendTokenScopes interface"
- path: ".planning/notes/2026-10-23-frontend-token-scopes.md"
provides: "the recorded E-2 design decision"
contains: "HasFrontendTokenScopes"
- path: "modules/towel/context.go"
provides: "client address on the context"
contains: "func ClientIP("
- path: "modules/cabana/account_contributions.go"
provides: "URL scheme rule and per-section isolation"
contains: "Error"
key_links:
- from: "modules/bouncer/refresh.go"
to: "modules/bouncer/mint.go"
via: "refreshAudience passes the presented token's auth_time to the internal mint"
pattern: "auth_time"
- from: "modules/cabana/account_tokens.go"
to: "modules/pier/events.go"
via: "the revoked event carries TokenID and pier closes only matching sessions"
pattern: "TokenID"
- from: "modules/pier/pier.go"
to: "modules/cabana/token_resolver.go"
via: "bearer puts towel.WithClientIP on the resolve context; ResolveBackendToken logs it"
pattern: "ClientIP"
---
<objective>
Close lane B of the phase code review (B-WR-01..B-WR-06 and the cheap B-IN items) and add the two framework pieces the plugins need in plan 08:
- the `auth_time` claim that makes the account link's fresh-sign-in proof real (C-CR-02, framework part);
- the plugin-declared member token scope seam escalated by sm-servers-app (E-2, `19.7-GAP-INPUTS.md`).
**Serialization (planner decision, forced by file overlap):**
- The user confirmed waves 06 = 1, 07 = 1, 08 = 2 and 09 = 3. This plan instead depends on 19.7-06, which makes the waves 1, 2, 3 and 4.
- The overlap: both plans edit `modules/pier/{session,stateless,store,pier}.go`, `modules/pact/mcp.go` and `modules/pact/README.md`. The token-specific revoke event in Task 3 also builds on 06's session and stateless matching code.
- Disjoint file sets would have meant moving B-WR-04 out of this plan, against the confirmed split.
- The plan count (four) and the execution order 06 → 07 → 08 → 09 are unchanged. The orchestrator reports the change to the user.
**Planner decisions recorded here (P-29..P-33):**
- **P-29 (auth_time, C-CR-02):** the claim is `auth_time` (OIDC Core's name, NumericDate seconds), exported as `bouncer.ClaimAuthTime`.
- `MintAudience` (and so `Mint`) stamps it equal to `iat`, because every direct mint is a sign-in: password login, auto-activated registration, activation by code, social login, the cabana admin login and its re-mint after a password change.
- `refreshAudience` copies the presented token's value unchanged. A token without one is refreshed without one; it is never stamped "now".
- Plugins read it with `bouncer.VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)`, which runs exactly the checks `VerifyClaims` runs for `AudienceUser` and `VerifyClaimsAudience` runs for other audiences. `ok` is false when the claim is absent.
- Settled by the review's suggested fix, so there is no decision checkpoint. Reversibility costly: once sessions carry the claim and plugins read it, renaming needs both names for a refresh window.
- **P-30 (E-2 seam, the open design point in GAP-INPUTS):** **a separate capability, not a reuse of `pact.HasTokenScopes` and not an audience field.**
- Shape: `pact.FrontendTokenScope{Name, Label, Description}` and `pact.HasFrontendTokenScopes{ FrontendTokenScopes() []FrontendTokenScope }`. This mirrors the existing `Permission`/`FrontendPermission` pair.
- Reasons:
1. The escalation asks for no change to the backend `g15_` catalog. Reusing the interface would show every member scope in the admin's token catalog, and every admin scope in the member list.
2. `pact.TokenScope` carries `Permissions` (cabana permission patterns) and `Methods` (an HTTP method filter). Neither applies to a member token, whose route middleware `user.scope:<name>` decides. cabana's boot validation requires both fields, so a member scope could not even be declared through it.
3. An audience field would leave two fields meaningless for one audience, and cabana would have to skip entries silently.
- `pact.ValidTokenScopeName(name string) bool` carries cabana's existing pattern, so both catalogs validate names identically. cabana switches to it with no behaviour change.
- The framework never reads `HasFrontendTokenScopes`. The user plugin compiles it in plan 08, as it does `HasFrontendPermissions`.
- Written down as `.planning/notes/2026-10-23-frontend-token-scopes.md`. Reversibility costly: a published framework API.
- **P-31 (token-specific revocation, B-WR-04 and the framework half of C-IN-02):** `pact.MCPSubjectChanged` gains `TokenID uint` and `ClientID string`, and `pier.Event` carries both as `omitempty` JSON. For `MCPSubjectRevoked`:
- a non-zero `TokenID` ends only the sessions whose subject under `Guard` has that `TokenID` and no `ClientID` (personal-token sessions);
- a non-empty `ClientID` ends only the sessions whose subject has that `ClientID`;
- neither ends every session of the subject (today's behaviour);
- other kinds ignore the fields.
cabana fires the admin token revoke with the token row id.
- **P-32 (client address, B-IN-04 and the seam for C-IN-04):**
- `towel.WithClientIP` and `towel.ClientIP` carry the client address on a context.
- pier's `bearer` sets it before resolving, from the host's optional `ClientIP(*http.Request) string` (surf's adapter uses `surf.ClientIP` with `http.trusted_proxies`), falling back to the host part of `RemoteAddr`.
- `cabana.ResolveBackendToken` logs it.
- **P-33 (My account seam, B-WR-03 and B-WR-06):**
- An action result URL is kept only when it is `https`/`http` absolute or same-origin relative (starts with one `/`, not `//`); anything else is dropped with an error log.
- A section whose `Status` fails is sent with `Status` empty and `Error: true` (`json:"error,omitempty"`, additive). An action whose `Applies` fails is omitted with a log. The list never answers 500 for one plugin's failure.
Purpose: lane B is fixed before v0.5.0, and plan 08 has `bouncer.VerifyAuthTime`, `pact.HasFrontendTokenScopes`, the token-specific events and `towel.ClientIP` to build on.
Output: framework commits in bouncer, pact, pier, surf, towel and cabana; the admin SPA fixes with a rebuilt committed dist; the regenerated OpenAPI; docs; one decision note; one tracer smoke test.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-01-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md
@CLAUDE.md
</context>
<project_rules>
- Unit tests are plan 09's job. This plan carries one tracer smoke, `TestAuthTimeSmoke`, and keeps every existing test green. A test that pinned the old wrong behaviour (for example the per-admin session kill on token revoke) is changed in the fix commit and named in the SUMMARY.
- Documentation rule: every exported API change updates the module README and the affected `docs/` page in the same commit; `summer docs:build --check` must stay green.
- Admin rule (CLAUDE.md): swag annotations change → `scripts/check-admin-openapi.sh` regenerates `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` in the same commit; `admin/` changes → `npm --prefix admin run build`, commit `modules/boardwalk/dist`, and `scripts/check-admin-dist.sh` passes. Run `npm --prefix admin ci` first if `admin/node_modules` is missing.
- Response shapes stay as they are (core value): the only payload change is the additive `error` key on a My account section. Existing `/account*` and `/auth/me` payloads stay byte-identical (the gate checks `modules/cabana/account.go` against 51d088a; this plan must not touch that file).
- Several sessions work in this repo, cabana especially: run `git status --short modules/cabana modules/pier modules/pact admin modules/boardwalk/dist` before every task and leave foreign edits alone. Stage by explicit path, one logical change per commit, no co-author tags; the decision note and the SUMMARY are planning docs in their own commits.
- No new dependency (go.mod, go.sum, admin/package-lock.json unchanged).
- Every commit is self-contained. At each commit:
- the framework builds, `go vet ./...` is clean and the `-short` tests pass;
- generated files (admin.json, schema.d.ts) match their annotations;
- the committed admin dist matches the SPA source.
No commit relies on a later task's commit.
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: auth_time is minted at sign-in, survives refresh unchanged and is readable through bouncer.VerifyAuthTime</name>
<reversibility rating="costly">A JWT claim that plugins read (the account-link proof in plan 08); renaming it later needs both names accepted for a whole refresh window.</reversibility>
<files>modules/bouncer/mint.go, modules/bouncer/refresh.go, modules/bouncer/jwt.go, modules/bouncer/auth_time_smoke_test.go, modules/bouncer/README.md, docs/services/authentication.md</files>
<read_first>
- 19.7-REVIEW.md, section C-CR-02 (the failure scenario and the suggested fix)
- modules/bouncer/mint.go (`registeredClaims`, `MintAudience`), refresh.go (`refreshAudience`, which calls `MintAudience` after its checks; `RefreshAudienceFor`), jwt.go (`VerifyClaims`, `VerifyClaimsAudience`, `verifyClaims`, `frontendAudienceOK`, `claimTime`)
- modules/bouncer/mint_test.go and refresh_test.go (what the existing tests pin about the claim set)
- modules/cabana/auth.go lines 200-270 and account.go around line 411 (the backend mints and refresh)
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go (the consumer in plan 08: it re-reads the session token with `bouncer.VerifyClaims` today)
- modules/bouncer/README.md (API reference table) and docs/services/authentication.md (token lifecycle section)
</read_first>
<action>
Implement P-29 for C-CR-02.
In mint.go:
- Add the exported constant `ClaimAuthTime = "auth_time"`.
- Add `AuthTime *jwt.NumericDate \`json:"auth_time,omitempty"\`` to `registeredClaims`.
- Move the body of `MintAudience` into an unexported `mintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string, authTime time.Time)`. It sets `AuthTime` when `authTime` is non-zero.
- `MintAudience` keeps its signature and calls `mintAudience` with `now`, so `auth_time` equals `iat` on every sign-in mint.
In refresh.go `refreshAudience`:
- Read the presented token's `auth_time` with `claimTime(claims, ClaimAuthTime)`.
- Mint the rotated token through `mintAudience` with that value. When the claim is absent, pass the zero time, so the rotated token carries none.
- Every exported refresh function keeps its signature.
In jwt.go, add `func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)`:
- it runs exactly the verification `VerifyClaims` runs for `AudienceUser` (which accepts a frontend token without `aud`) and `VerifyClaimsAudience` runs for any other audience: signature, audience, `exp`, required claims;
- it returns the subject and the `auth_time` claim;
- `ok` is false when the claim is missing or not a number.
Update existing tests that compare the exact claim set of a minted token in the same commit.
New file `modules/bouncer/auth_time_smoke_test.go` holds `TestAuthTimeSmoke`:
1. `Mint` a token, then `VerifyAuthTime` returns `ok` and a time equal to `iat` (seconds).
2. Sleep past one second (or mint with a past clock through `mintAudience` from an in-package test), call `Refresh`, and `VerifyAuthTime` on the new token returns the original auth_time while its `iat` is later.
3. A token minted by the test with `registeredClaims` and no `AuthTime` refreshes into a token where `ok == false`.
4. A token with the wrong audience, a bad signature or past `exp` answers an error.
Docs in the same commit:
- modules/bouncer/README.md: API rows for `ClaimAuthTime` and `VerifyAuthTime`, and a Features line saying refresh keeps the sign-in time.
- docs/services/authentication.md: a short "Sign-in time (auth_time)" subsection explaining that a "fresh sign-in" check compares `auth_time`, never `iat`, because refresh renews `iat`.
</action>
<verify>
<automated>go vet ./modules/bouncer/... ./modules/cabana/... && go test -count=1 -short ./modules/bouncer/... ./modules/cabana/... && go test -count=1 -short -run '^TestAuthTimeSmoke$' -v ./modules/bouncer/ 2>&1 | grep -q -- '^--- PASS: TestAuthTimeSmoke' && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, "FAIL" in the output, the PASS line missing, or the docs stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'ClaimAuthTime = "auth_time"' modules/bouncer/mint.go` prints 1 and `grep -c "func mintAudience(" modules/bouncer/mint.go` prints 1
- `grep -v '^\s*//' modules/bouncer/refresh.go | grep -c "mintAudience("` prints at least 1 and `grep -v '^\s*//' modules/bouncer/refresh.go | grep -c "ClaimAuthTime"` prints at least 1
- `grep -c "func VerifyAuthTime(tokenString, secret, audience string) (sub string, authTime time.Time, ok bool, err error)" modules/bouncer/jwt.go` prints 1
- `grep -c "VerifyAuthTime\|ClaimAuthTime" modules/bouncer/README.md` prints at least 2 and `grep -c "auth_time" docs/services/authentication.md` prints at least 1
- `grep -c "func TestAuthTimeSmoke(" modules/bouncer/auth_time_smoke_test.go` prints 1
</acceptance_criteria>
<done>Every sign-in mint carries auth_time, refresh never renews it, a legacy token stays without it, and plugins have one documented accessor; the smoke proves mint → refresh → read end to end.</done>
</task>
<task type="auto">
<name>Task 2: The E-2 framework seam: pact.HasFrontendTokenScopes, the shared scope-name rule, and the recorded decision</name>
<reversibility rating="costly">A published pact capability (decision P-30); plugins and the user plugin compile against it, so a later reshape needs a deprecation cycle.</reversibility>
<files>modules/pact/capabilities.go, modules/cabana/token_scopes.go, modules/pact/README.md, docs/plugins/registration.md, docs/backend/users-and-permissions.md, .planning/notes/2026-10-23-frontend-token-scopes.md</files>
<read_first>
- .planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md, section "E-2" (the ask, "Not asked for", acceptance and the open design point)
- modules/pact/capabilities.go (`TokenScope`, `HasTokenScopes`; the doc at line ~367 on prompted record actions is Task 4's); modules/pact/mcp.go (`FrontendPermission`, `HasFrontendPermissions`, the style to mirror)
- modules/cabana/token_scopes.go (`tokenScopeNameRE`, `validatePluginTokenScope`)
- modules/pact/README.md (Features list and type table rows for TokenScope and FrontendPermission); docs/plugins/registration.md (capability table, lines ~80-95); docs/backend/users-and-permissions.md (the `pact.HasTokenScopes` section around line 92)
- .planning/notes/ (an existing decision note's shape, for example 2026-10-22-mcp-go-sdk.md)
</read_first>
<action>
Implement P-30.
In modules/pact/capabilities.go, next to `TokenScope`/`HasTokenScopes`:
- `type FrontendTokenScope struct { Name, Label, Description string }`. Doc: one scope of the user plugin's member personal API tokens (`usr_`). Label and Description are phrase keys (containing "::") or literal text. The name is what a route's `user.scope:<name>` middleware checks.
- `type HasFrontendTokenScopes interface { FrontendTokenScopes() []FrontendTokenScope }`. Doc: the frontend counterpart of `HasTokenScopes`. The framework's admin token catalog never reads it; the plugin that owns member tokens compiles it.
- `func ValidTokenScopeName(name string) bool`, with the pattern `^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$` compiled once in pact. Doc: the one name rule for both catalogs.
In modules/cabana/token_scopes.go, replace the body of the name check with `pact.ValidTokenScopeName`. Remove the local regexp only if nothing else uses it. Every error message stays word for word.
Docs in the same commit:
- modules/pact/README.md: a Features line and two type rows plus the function row.
- docs/plugins/registration.md: a capability table row.
- docs/backend/users-and-permissions.md: one paragraph after the `pact.HasTokenScopes` section saying member token scopes are declared with `pact.HasFrontendTokenScopes`, appear only in the member token list, and never in the admin catalog.
Write `.planning/notes/2026-10-23-frontend-token-scopes.md` (title "Decision: member token scopes use a separate pact capability") with these sections:
- Context: the E-2 escalation, with what was asked and what was not;
- Options: reuse `HasTokenScopes`, an audience field, a separate interface;
- Decision and reasons: the three reasons in P-30;
- Consequences: the user plugin compiles and validates the scopes and fails boot on bad ones; the cabana catalog is unchanged; `pact.ValidTokenScopeName` is shared;
- Related: GAP-INPUTS E-2, plan 08 Task 3.
Commit the note separately as a planning doc.
</action>
<verify>
<automated>go vet ./modules/pact/... ./modules/cabana/... && go test -count=1 -short ./modules/pact/... ./modules/cabana/... && go test -count=1 -short -run '^(TestPactStaysSDKFree|TestPhase197ContractsOnSamplePlugin)$' -v ./modules/pact/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 2 PASS lines, or a gate stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type FrontendTokenScope struct\|type HasFrontendTokenScopes interface\|func ValidTokenScopeName(" modules/pact/capabilities.go` prints 3
- `grep -v '^\s*//' modules/cabana/token_scopes.go | grep -c "pact.ValidTokenScopeName("` prints at least 1
- `grep -rn "HasFrontendTokenScopes" modules/cabana --include=*.go | grep -v _test | wc -l` prints 0 (cabana never reads the member capability)
- `grep -c "HasFrontendTokenScopes" modules/pact/README.md docs/plugins/registration.md docs/backend/users-and-permissions.md` prints at least 1 per file
- `test -f .planning/notes/2026-10-23-frontend-token-scopes.md && grep -c "HasTokenScopes\|audience" .planning/notes/2026-10-23-frontend-token-scopes.md` prints at least 2
</acceptance_criteria>
<done>The framework offers the member token scope capability and one shared name rule, cabana's admin catalog is untouched in behaviour, and the E-2 design decision is recorded as a note.</done>
</task>
<task type="auto">
<name>Task 3: Token- and client-specific revocation events, and the client address on pier's resolve path</name>
<reversibility rating="costly">New fields on the published pact.MCPSubjectChanged event and on pier.Event's wire JSON between instances (additive and omitempty, so mixed-version instances ignore them).</reversibility>
<files>modules/pact/mcp.go, modules/pier/store.go, modules/pier/events.go, modules/pier/session.go, modules/pier/stateless.go, modules/pier/pier.go, modules/pier/host.go, modules/surf/pier_on.go, modules/towel/context.go, modules/cabana/account_tokens.go, modules/cabana/subject_events.go, modules/cabana/token_resolver.go, modules/pact/README.md, modules/pier/README.md, modules/towel/README.md, modules/cabana/README.md, docs/services/mcp.md</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-04, B-IN-04 and C-IN-02
- modules/pact/mcp.go: `MCPSubjectChanged`, `MCPSubject` (ClientID, TokenID)
- modules/pier/events.go (`listenEvents`, `handleEvent`), store.go (`Event`), session.go (`matching`, and the matching method 06 left), stateless.go (`matching`, `evict`), identity.go (`Identity.has`, `subjectFor`; which subjects carry ClientID and TokenID for OAuth, personal frontend and admin `g15_` tokens)
- modules/pier/pier.go `bearer` (where `identities.resolve` is called); host.go (`Host`); modules/surf/pier_on.go (`pierHost`, `mountPier`); modules/surf/clientip.go (`ClientIP`, `TrustedProxies`)
- modules/towel/context.go (`withValue`, `stringValue`, `WithActor`/`Actor` as the model)
- modules/cabana/account_tokens.go around line 155-170 (the revoke handler) and subject_events.go (`fireSubjectChanged`); token_resolver.go (`ResolveBackendToken`, its `logTokenAuthAt` calls) and token_guard.go (`logTokenAuthAt`)
</read_first>
<action>
**P-31.**
- In pact/mcp.go, add `TokenID uint` and `ClientID string` to `MCPSubjectChanged`. Rewrite its doc to state the narrowing rule for `MCPSubjectRevoked`; other kinds ignore both fields.
- In pier/store.go, add `TokenID uint \`json:"token_id,omitempty"\`` and `ClientID string \`json:"client_id,omitempty"\`` to `Event`.
- `listenEvents` copies both.
- In `handleEvent`'s revoked/deleted branch, select sessions with a predicate:
- with `TokenID != 0`: sessions whose subject under `ev.Guard` has that `TokenID` and an empty `ClientID`;
- with `ClientID != ""`: sessions whose subject under `ev.Guard` has that `ClientID`;
- otherwise: today's `has(guard, userID)`.
- Deleted events always use the whole subject.
- Add the predicate form to `sessionRouter.matching` and to `statelessPath.matching`/`evict`, keeping the existing signatures as wrappers.
- In cabana/subject_events.go, add an unexported `fireTokenRevoked(ctx, app, adminID, tokenID uint)` that fires `&pact.MCPSubjectChanged{Guard: "backend", UserID: adminID, Kind: pact.MCPSubjectRevoked, TokenID: tokenID}` like `fireSubjectChanged`.
- In account_tokens.go, the revoke handler calls it with `row.ID` instead of the per-admin event. Fix the misleading comment above the call so it says which sessions end.
**P-32.**
- In towel/context.go, add `WithClientIP(ctx context.Context, ip string) context.Context` and `ClientIP(ctx context.Context) (string, bool)` on an unexported key, following `WithActor`/`Actor`.
- In pier, declare an unexported interface in host.go, `clientIPHost interface{ ClientIP(*http.Request) string }`. Document it as optional for hosts.
- In surf/pier_on.go, `pierHost` gains a `trusted []netip.Prefix` field, filled in `mountPier` from `TrustedProxies(app.Config)`, and a `ClientIP` method calling `ClientIP(req, h.trusted)`.
- In pier.go `bearer`, before `p.identities.resolve`, wrap the context with `towel.WithClientIP`. Use the host's answer when it implements the interface; otherwise use the host part of `r.RemoteAddr` (`net.SplitHostPort`, or the raw value on error).
- In cabana/token_resolver.go, pass `towel.ClientIP(ctx)` as the `remote` of every `logTokenAuthAt` call in `ResolveBackendToken`.
Docs in the same commit:
- pact README: the `MCPSubjectChanged` row.
- pier README: the fan-out section's narrowing rule.
- towel README: the API rows.
- cabana README: the token revoke event and resolver logging.
- docs/services/mcp.md: the fan-out paragraph.
</action>
<verify>
<automated>go vet ./... && go vet -tags summercms_mcp ./modules/surf/... ./modules/pier/... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/towel/... ./modules/cabana/... && go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/... && go test -count=1 -short -run '^(TestFanOut|TestPierFanOutSmoke|TestFireSubjectChangedReachesPointerListener)$' -v ./modules/pier/ ./modules/cabana/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && bash scripts/check-phase19.7.sh --docs && bash scripts/check-phase19.7.sh --marker</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 3 PASS lines, or a gate stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -A6 "type MCPSubjectChanged struct" modules/pact/mcp.go | grep -c "TokenID\|ClientID"` prints 2
- `grep -c 'json:"token_id,omitempty"\|json:"client_id,omitempty"' modules/pier/store.go` prints 2
- `grep -v '^\s*//' modules/pier/events.go | grep -c "TokenID\|ClientID"` prints at least 2
- `grep -v '^\s*//' modules/cabana/account_tokens.go | grep -c "fireTokenRevoked("` prints 1
- `grep -c "func WithClientIP(\|func ClientIP(" modules/towel/context.go` prints 2 and `grep -v '^\s*//' modules/cabana/token_resolver.go | grep -c "towel.ClientIP("` prints at least 1
- `grep -v '^\s*//' modules/pier/pier.go | grep -c "towel.WithClientIP("` prints 1 and `grep -c "func (h pierHost) ClientIP(" modules/surf/pier_on.go` prints 1
</acceptance_criteria>
<done>Revoking one admin token ends only what that token opened; the event shape can also target one OAuth client (used by the plugins in plan 08); failed g15_ attempts at /mcp carry the client address and resolvers can read it.</done>
</task>
<task type="auto">
<name>Task 4: cabana admin API: account action URL rule, per-section isolation, localized texts, the prompted record action contract, regenerated OpenAPI</name>
<files>modules/cabana/account_contributions.go, modules/cabana/actions.go, modules/cabana/admin_openapi.go, modules/pact/capabilities.go, modules/cabana/README.md, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/lang.json, admin/openapi/admin.json, admin/src/api/schema.d.ts</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-03 (server part), B-WR-05, B-WR-06 (server part), B-IN-02, B-IN-03, B-IN-05
- modules/cabana/account_contributions.go (`accountContributionsList`, `AccountContributionView`, the action handler around line 200 that writes `AccountActionResultView{URL: result.URL}`); modules/cabana/crud.go `localizeDetail`
- modules/cabana/actions.go lines 240-280 (`promptRequiredKey` and the three prompt refusals)
- modules/cabana/admin_openapi.go lines 790-830 (`AdminRecordAction` description, the body param text, `AdminActionRequest.Payload`) and the My account contributions annotations (the view type's doc)
- modules/pact/capabilities.go around line 367 (the prompted record action doc that promises a validation error on the input)
- modules/phrasebook/backend/lang/en/lang.yaml `form:` and `myaccount:` sections, the pl twin, admin/tests/fixtures/lang.json
- scripts/check-admin-openapi.sh (how to regenerate vs `--check`)
</read_first>
<action>
**B-WR-03, server (P-33).** Before `WriteData` in the action handler, keep `result.URL` only when one of these holds:
- `url.Parse` succeeds with scheme `https` or `http` and a host;
- or it is a same-origin relative reference (starts with exactly one `/`, not `//`, and has no scheme).
Otherwise set it to "" and log `cabana: account contribution action answered a non-http URL` with section and action, never the URL itself.
**B-WR-06, server (P-33).**
- Add `Error bool \`json:"error,omitempty"\`` to `AccountContributionView`, documented as "the section's status could not be loaded".
- When `Status` errors, log as today, leave `Status` empty, set `Error`, and continue.
- When an action's `Applies` errors, log and skip that action.
- The handler never answers 500 for a plugin's error. A database or registry failure outside the plugin hooks still does.
**B-IN-03.** Translate a section's status with `localizeDetail(ctx, tr, status)` instead of `translateKey`.
**B-IN-02.** In actions.go, replace the hard-coded English refusal for a non-string payload with the phrase key `backend::lang.form.action_prompt_invalid` (en "The answer must be text." / pl "Odpowiedź musi być tekstem."), added to both lang files and to admin/tests/fixtures/lang.json.
**B-IN-05.** Rewrite the sentence in pact/capabilities.go that promises a validation error on the input. It should say what happens: the admin refuses the value with a 422 whose message is shown as a toast, and the prompt dialog closes. The dialog-stays-open UX is deferred (recorded by plan 09 in deferred-items).
**B-WR-05, in admin_openapi.go.** Replace the stale sentence in the `AdminRecordAction` description with: the body is `{}` for an action without a prompt; an action whose record response carries `prompt` takes `{"payload": "<text>"}`, trimmed, non-empty and at most `prompt.max_length` runes, with a 422 on `payload` otherwise. Make these match:
- the body param text: "Empty object, or {"payload": text} for a prompted action";
- the `AdminActionRequest.Payload` comment: toolbar routes refuse it; record routes accept a JSON string only for a prompted action.
Document the new `error` key wherever the contribution view type is annotated.
Run `scripts/check-admin-openapi.sh` to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts, then `scripts/check-admin-openapi.sh --check`. The cabana README's My account section gains the URL rule and the `error` flag. Commit the code, the generated files and the docs together.
</action>
<verify>
<automated>go vet ./modules/cabana/... ./modules/pact/... && go test -count=1 -short ./modules/cabana/... ./modules/pact/... && go test -count=1 -short -run '^(TestAccountContributions|TestAccountContributionsSmoke|TestRecordActionPrompt|TestRecordActionPromptSmoke)$' -v ./modules/cabana/ 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4 && bash scripts/check-admin-openapi.sh --check && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, "FAIL" in the output, fewer than 4 PASS lines, an OpenAPI drift report, or the docs stage printing FAIL or "refuse:"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'json:"error,omitempty"' modules/cabana/account_contributions.go` prints 1 and `grep -c "non-http URL" modules/cabana/account_contributions.go` prints 1
- `grep -v '^\s*//' modules/cabana/account_contributions.go | grep -c "localizeDetail("` prints at least 1
- `grep -c "action_prompt_invalid" modules/cabana/actions.go modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml admin/tests/fixtures/lang.json` prints at least 1 per file
- `grep -c "payload are all refused" modules/cabana/admin_openapi.go admin/openapi/admin.json` prints 0 for both files and `grep -c "prompt.max_length" admin/openapi/admin.json` prints at least 1
- `bash scripts/check-admin-openapi.sh --check` exits 0
- `git diff --quiet 51d088a -- modules/cabana/account.go` (My account's own handlers untouched)
</acceptance_criteria>
<done>The admin API only hands out safe URLs, one broken contributing plugin cannot blank My account, the remaining English text is localized, the prompted-action contract is published correctly and the generated API files match.</done>
</task>
<task type="auto">
<name>Task 5: Admin SPA: shadow-DOM-aware plain keys, commit-before-save shortcuts, safe account links, the section error state and My account texts; rebuilt dist</name>
<files>admin/src/app/hotkeys.ts, admin/src/components/account/AccountContributions.vue, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/tests/fixtures/lang.json, modules/boardwalk/dist</files>
<read_first>
- 19.7-REVIEW.md, sections B-WR-01, B-WR-02, B-WR-03 (SPA part), B-WR-06 (SPA part), B-IN-01, B-IN-06
- admin/src/app/hotkeys.ts (`isEditableTarget` at ~100-105, `onModifierKey` at ~187-206 with `target.click()`, `onPlainKey` at ~208-236)
- admin/src/components/form/fields/TaglistField.vue (`addDraft` on blur and Enter, lines ~73 and ~167)
- admin/src/components/account/AccountContributions.vue (`load`, the confirm texts at ~52 and ~78, `links` and `window.open` at ~43-46 and ~103-111)
- admin/tests/app/hotkeys.test.ts and admin/tests/account/AccountContributions.test.ts (existing expectations to keep green)
- modules/phrasebook/backend/lang/en/lang.yaml `myaccount:` and `form:` sections
- scripts/check-admin-dist.sh
</read_first>
<action>
**B-WR-01.**
- In hotkeys.ts, add `originOf(event)`: the first entry of `event.composedPath()` when available, else `event.target`.
- `onPlainKey` tests `isEditableTarget(originOf(event))`.
- `isEditableTarget` also treats any element whose tag name contains `-` (a custom element) and that sits inside a `form` or `[role=form]` as editable. That covers closed shadow roots, where `composedPath()` stops at the host.
**B-WR-02.** In `onModifierKey`, before `target.click()`:
- when `document.activeElement` is an element other than the target and has `blur`, call `blur()`, so TaglistField's `addDraft` and native `change` run as a pointer click would trigger them;
- restore focus to the element after the click only when it is still in the document and the view did not navigate (keep it simple: refocus when `isConnected`).
**B-WR-03, SPA.** In AccountContributions.vue, add `safeUrl(u: string): string`, returning the value only for `/^https?:\/\//i` or a single-slash relative path, else ''. Use it for both the rendered `href` and `window.open`. An unsafe URL is never bound and never opened. B-IN-06 stays as is: the visible link remains the fallback for a blocked popup, with no code change.
**B-WR-06, SPA.**
- On a non-2xx GET of the contributions, show a toast with `backend::lang.form.error_generic` and keep the previous sections.
- A section with `error: true` renders a muted line `backend::lang.myaccount.section_error` (en "This section could not be loaded." / pl "Nie udało się wczytać tej sekcji.").
**B-IN-01.** Use the new keys `backend::lang.myaccount.action_confirm` (en "Run “:action”?" / pl "Wykonać „:action”?") and `backend::lang.myaccount.action_stale` (en "This action no longer applies. The page has been refreshed." / pl "Ta akcja nie ma już zastosowania. Strona została odświeżona.") in place of the record-oriented form texts.
Add every new key to the en and pl lang files and to admin/tests/fixtures/lang.json. Adjust existing vitest expectations that asserted the record-oriented texts in the same commit (plan 09 adds the new tests).
Run `npm --prefix admin run typecheck`, `npm --prefix admin run test`, `npm --prefix admin run build`, then `scripts/check-admin-dist.sh`. Commit the SPA source, the lang changes and the rebuilt `modules/boardwalk/dist` in one commit, so `check-admin-dist.sh` passes at that commit (self-contained rule).
</action>
<verify>
<automated>npm --prefix admin run typecheck && npm --prefix admin run test 2>&1 | grep -E 'Test Files +[0-9]+ passed' | grep -qv failed && bash scripts/check-admin-dist.sh && bash scripts/check-phase19.7.sh --admin</automated>
<fails_when>non-zero exit, a failed or skipped vitest file, a dist drift report from check-admin-dist.sh, or the admin stage printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "composedPath" admin/src/app/hotkeys.ts` prints at least 1 and `grep -v '^\s*//' admin/src/app/hotkeys.ts | grep -c "\.blur()"` prints at least 1
- `grep -c "safeUrl" admin/src/components/account/AccountContributions.vue` prints at least 3 (definition, href binding, window.open)
- `grep -c "myaccount.action_confirm\|myaccount.action_stale\|myaccount.section_error" admin/src/components/account/AccountContributions.vue` prints at least 3
- `grep -c "section_error\|action_confirm\|action_stale" modules/phrasebook/backend/lang/pl/lang.yaml` prints at least 3 and the same keys are in admin/tests/fixtures/lang.json
- `bash scripts/check-admin-dist.sh` exits 0 at the task's commit (source and dist in the same commit)
</acceptance_criteria>
<done>Typing inside plugin field types is safe from plain shortcuts, keyboard saves include pending drafts, My account never renders or opens a script URL, a broken section shows an error instead of vanishing, the texts fit My account, and the embedded SPA matches its source.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Framework code (summercms.go, local commits on master):**
- `bouncer`: `ClaimAuthTime`, `VerifyAuthTime`, the auth_time mint and refresh carry-over.
- `pact`:
- `FrontendTokenScope`, `HasFrontendTokenScopes`, `ValidTokenScopeName`;
- `MCPSubjectChanged.TokenID` and `ClientID`;
- the corrected prompted-action doc.
- `pier`: event narrowing, `Event.TokenID`/`ClientID`, the client address on the resolve context, and the optional host capability.
- `surf`: `pierHost.ClientIP`.
- `towel`: `WithClientIP` and `ClientIP`.
- `cabana`:
- the token-specific revoke event;
- the resolver's remote address;
- the account action URL rule, the section `error` flag and status localization;
- the localized payload refusal;
- the corrected record action annotations.
**Admin SPA:**
- `hotkeys.ts` (composedPath, blur before click);
- `AccountContributions.vue` (`safeUrl`, the error state, the My account texts);
- the rebuilt `modules/boardwalk/dist`;
- the regenerated `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`.
**Docs:**
- READMEs: bouncer, pact, pier, towel, cabana;
- `docs/services/authentication.md`, `docs/services/mcp.md`, `docs/plugins/registration.md`, `docs/backend/users-and-permissions.md`.
**Decision note:** `.planning/notes/2026-10-23-frontend-token-scopes.md` (E-2, P-30).
**Test:** `modules/bouncer/auth_time_smoke_test.go` (`TestAuthTimeSmoke`).
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser session to the token refresh endpoints | any holder of a token inside its refresh window can rotate it without a password |
| plugins to the admin token catalog and the member token catalog | plugins declare scope names that end up on stored tokens |
| plugin account actions to the admin SPA | a plugin answers a URL the admin's browser renders and opens |
| admin and plugins to pier's fan-out | revocation events decide which live MCP sessions end |
| MCP clients to the token resolvers | failed attempts must be traceable to an address |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-51 | Spoofing | the fresh-sign-in proof (D-05) | high | mitigate | auth_time stamped only at sign-in, copied unchanged by refresh, absent stays absent; `VerifyAuthTime` checks signature, audience and expiry; plan 08 makes the link confirm compare auth_time; TestAuthTimeSmoke here, TestAuthTime and TestBackendLinkAuthTime in plan 09 |
| T-19.7-52 | Elevation of privilege | member scopes crossing into the admin token catalog, or the reverse | medium | mitigate | separate capability that cabana never reads (acceptance grep); one shared name rule `pact.ValidTokenScopeName` |
| T-19.7-53 | Tampering | a `javascript:`/`data:` URL from an account action rendered into href or window.open | medium | mitigate | server keeps only http(s) and same-origin relative URLs; SPA `safeUrl` on both sinks |
| T-19.7-54 | Denial of service | revoking one token ends every MCP session of the admin, including OAuth sessions of the linked user | low | mitigate | token-specific revoked event; client-specific variant for plan 08 |
| T-19.7-55 | Repudiation | failed g15_ attempts at /mcp logged without a client address | low | mitigate | towel client address on the resolve context, logged by ResolveBackendToken |
| T-19.7-56 | Information disclosure | one plugin's error silently hides other plugins' My account sections (including the Unlink action) | low | mitigate | per-section isolation with the `error` flag and a toast on a failed GET |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package added; `npm --prefix admin ci` installs the committed lock only; go.mod, go.sum and admin/package-lock.json unchanged |
</threat_model>
<verification>
- `go vet ./... && go test -count=1 -short ./...` and `go test -count=1 ./modules/cabana/...` on a Docker host (the cabana Postgres suites, through `sudo -u golem` if the socket is denied)
- `bash scripts/check-phase19.7.sh --go`, `--admin`, `--docs` and `--marker` print PASS
- `cd examples/hello && go test ./...` stays green
- `git diff --quiet 51d088a -- modules/cabana/account.go`
</verification>
<success_criteria>
- B-WR-01..B-WR-06 fixed; B-IN-01, B-IN-02, B-IN-03 and B-IN-04 fixed; B-IN-05's contract text corrected (the UX deferral is recorded by plan 09); B-IN-06 needs no change
- `bouncer.VerifyAuthTime`, `pact.HasFrontendTokenScopes`, `pact.ValidTokenScopeName`, `pact.MCPSubjectChanged.TokenID/ClientID` and `towel.ClientIP` exist with docs, ready for plan 08
- The E-2 design decision is recorded (P-30 and the note)
- No exported identifier removed; every response shape unchanged except the additive `error` key
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md` when done, with every commit sha mapped to its finding ids. Commit the SUMMARY separately as a planning doc.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order: framework v0.5.0, then the plugin tags that require it, then the application bumps.
</output>

View File

@@ -0,0 +1,218 @@
---
phase: 19.7-generic-mcp-module
plan: 07
subsystem: auth
status: complete
gap_closure: true
tags: [bouncer, jwt, auth_time, pact, token-scopes, pier, mcp, fan-out, towel, cabana, admin-spa, hotkeys, openapi, gap-closure]
requires:
- phase: 19.7-06
provides: pier session and stateless matching after lane A (session.go, stateless.go, events.go), pact.MCPPrompt.Writes
- phase: 19.7-05
provides: scripts/check-phase19.7.sh (--go, --admin, --docs, --marker stages)
provides:
- bouncer.ClaimAuthTime and bouncer.VerifyAuthTime; auth_time stamped at every sign-in mint and copied unchanged by every refresh path
- pact.FrontendTokenScope, pact.HasFrontendTokenScopes and pact.ValidTokenScopeName (E-2 framework seam, decision note)
- pact.MCPSubjectChanged.TokenID / ClientID and pier.Event token_id / client_id; narrowed revocation in pier
- towel.WithClientIP / towel.ClientIP; pier puts the client address on the resolve context; cabana.ResolveBackendToken logs it
- cabana My account seam: http(s)/same-origin URL rule, per-section `error` flag, localized status; localized prompt refusal; corrected record action contract
- admin SPA: shadow-DOM-aware plain keys, blur-before-click shortcuts, safeUrl, section error line and toast, My account confirm/stale texts; rebuilt dist
affects: [19.7-08 (user plugin reads VerifyAuthTime, compiles HasFrontendTokenScopes, fires client-specific revocations), 19.7-09 (named unit tests), framework v0.5.0]
actuals:
tokens: 28086
tasks: 5
commits: 6
plan_head_before: 60a86677f22aef02d11acc812ba110b94797e1ed
plan_head_after: 88559d5731c35d8071fb500ee93d7c8c287312e9
tech-stack:
added: []
patterns:
- "A refresh copies a sign-in claim from the presented token instead of re-deriving it (mintAudience with an explicit authTime)"
- "Frontend twin of a pact capability (FrontendTokenScope next to TokenScope, as FrontendPermission next to Permission) with one shared validation function"
- "Revocation events narrow by optional fields; pier selects sessions with a predicate (matchingFunc / evictFunc), the old (guard, user) signatures stay as wrappers"
- "Optional host capability discovered by type assertion (clientIPHost), so pier.Host does not change"
key-files:
created:
- modules/bouncer/auth_time_smoke_test.go
- .planning/notes/2026-10-23-frontend-token-scopes.md
modified:
- modules/bouncer/mint.go
- modules/bouncer/refresh.go
- modules/bouncer/jwt.go
- modules/pact/capabilities.go
- modules/pact/mcp.go
- modules/cabana/token_scopes.go
- modules/pier/events.go
- modules/pier/stateless.go
- modules/pier/store.go
- modules/pier/pier.go
- modules/pier/host.go
- modules/surf/pier_on.go
- modules/towel/context.go
- modules/cabana/account_tokens.go
- modules/cabana/subject_events.go
- modules/cabana/token_resolver.go
- modules/cabana/account_contributions.go
- modules/cabana/actions.go
- modules/cabana/admin_openapi.go
- admin/src/app/hotkeys.ts
- admin/src/components/account/AccountContributions.vue
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- modules/boardwalk/dist
key-decisions:
- "P-29: auth_time (OIDC name, NumericDate seconds) is stamped equal to iat by every MintAudience call; refreshAudience copies the presented value and never stamps a missing one; plugins read it with bouncer.VerifyAuthTime (VerifyClaims checks for AudienceUser, VerifyClaimsAudience checks otherwise; ok false when absent)"
- "P-30: member token scopes use a separate capability pact.HasFrontendTokenScopes, not HasTokenScopes and not an audience field; pact.ValidTokenScopeName is the one name rule; recorded in .planning/notes/2026-10-23-frontend-token-scopes.md"
- "P-31: a revoked event with TokenID ends only sessions whose subject under Guard is UserID with that TokenID and no ClientID; with ClientID only that client's sessions of UserID; neither (and every deleted event) the whole subject"
- "P-32: pier puts the client address on the resolve context (surf's adapter applies http.trusted_proxies, else the host part of RemoteAddr)"
- "P-33: account action URLs kept only for http(s) with a host or a same-origin path (one leading /, not // or /\\); a failing Status marks its section error: true, a failing Applies drops that action"
patterns-established:
- "Sign-in freshness is proven by auth_time, never iat"
requirements-completed: [SC-4, D-04, D-05, D-17, D-19, D-26, D-27, D-34, E-2]
duration: 62min
completed: 2026-10-23
---
# Phase 19.7 Plan 07: Lane B gap closure, auth_time and the E-2 seam Summary
Lane B of the phase code review is closed, and the framework gains the two pieces plan 08's plugins build on. Every sign-in token now carries `auth_time`, which a refresh copies unchanged, so the account link's fresh-sign-in proof can no longer be satisfied by `POST /refresh`. Plugins can declare member (`usr_`) token scopes through `pact.HasFrontendTokenScopes`, which the admin catalog never reads. Revoking one admin token ends only that token's MCP sessions, and failed `g15_` attempts at `/mcp` are logged with the client address. The My account seam only hands the browser http(s) or same-origin URLs and survives one plugin's failure. The SPA's plain-key and save shortcuts behave correctly around shadow DOM and pending taglist drafts.
## Performance
- **Duration:** about 62 min
- **Started:** 2026-10-23T14:11:39Z
- **Completed:** 2026-10-23
- **Tasks:** 5 of 5
- **Files modified:** 43 (2 created, generated dist assets renamed)
## Commits and findings
| Commit | Task | Findings |
|--------|------|----------|
| c14238b `feat(bouncer): stamp auth_time at sign-in and carry it unchanged through refresh` | 1 (tracer) | C-CR-02 (framework part) |
| 9eedfbb `feat(pact): add HasFrontendTokenScopes and the shared ValidTokenScopeName rule` | 2 | E-2 (framework seam) |
| 0ec00f7 `docs(19.7-07): record the member token scope design decision` | 2 | E-2 (P-30 note) |
| 24cf6cd `fix(pier): end only the revoked token's or client's MCP sessions and log the client address` | 3 | B-WR-04, B-IN-04, C-IN-02 (framework half) |
| 44835e4 `fix(cabana): safe account action URLs, per-section isolation and the prompted record action contract` | 4 | B-WR-03 (server), B-WR-05, B-WR-06 (server), B-IN-02, B-IN-03, B-IN-05 (contract text) |
| 88559d5 `fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links` | 5 | B-WR-01, B-WR-02, B-WR-03 (SPA), B-WR-06 (SPA), B-IN-01 |
B-IN-06 needs no change: the visible link stays the fallback when a popup blocker stops `window.open`. The B-IN-05 dialog-stays-open UX is deferred; plan 09 records it in deferred-items.
## Accomplishments
- **auth_time (Task 1, tracer).**
- `bouncer.ClaimAuthTime = "auth_time"`, with `registeredClaims.AuthTime` (`omitempty`).
- `MintAudience` keeps its signature and calls the unexported `mintAudience(..., now, authTime)` with the same instant for both, so `auth_time == iat` on every sign-in mint (cabana's admin login and its re-mint after a password change included).
- `refreshAudience` reads the presented `auth_time` with `claimTime` and mints through `mintAudience`. `Refresh`, `RefreshAudience` and `RefreshAudienceFor` (cabana's backend `/refresh`) all go through it, and a token without the claim refreshes without one.
- `bouncer.VerifyAuthTime(tokenString, secret, audience)` shares `parseVerified` with `verifyClaims`. That gives the same HS256, required `exp`, audience (`AudienceUser` also accepts a token with no `aud`) and subject checks.
- `TestAuthTimeSmoke` covers mint → read, refresh keeps `auth_time` while `iat` moves, legacy → no claim, and wrong audience, bad signature and expired token → error.
- **E-2 seam (Task 2).**
- New: `pact.FrontendTokenScope{Name, Label, Description}`, `pact.HasFrontendTokenScopes` and `pact.ValidTokenScopeName` (the pattern moved from cabana).
- cabana's `validatePluginTokenScope` calls it with identical messages. cabana never references `HasFrontendTokenScopes`.
- The decision note records the context, the three options, the reasons and the consequences.
- **Narrowed revocation and client address (Task 3).**
- `MCPSubjectChanged` and `pier.Event` gain `TokenID`/`ClientID` (JSON `token_id`/`client_id`, `omitempty`), and `listenEvents` copies them.
- `revokedMatch` builds the predicate. `sessionRouter.matchingFunc` and `statelessPath.matchingFunc`/`evictFunc` apply it, and the old `matching(guard, user)`/`evict(guard, user)` remain as wrappers.
- cabana's `fireTokenRevoked` fires after commit with the token row id. The revoke handler uses it, and the misleading comment is fixed.
- `towel.WithClientIP`/`towel.ClientIP` are new. pier's `bearer` wraps the resolve context with the address. The optional `clientIPHost` is implemented by surf's `pierHost.ClientIP`, which uses `TrustedProxies(app.Config)`; the fallback is the host part of `RemoteAddr`. `ResolveBackendToken` logs `remote` on all six `admin.auth` lines.
- **cabana admin API (Task 4).**
- `safeAccountActionURL` checks the action URL. A refused URL is dropped and logged without the URL.
- `AccountContributionView.Error` (`json:"error,omitempty"`) is set when `Status` fails. A failing `Applies` drops only that action, and the list no longer answers 500 for a plugin error.
- The status goes through `localizeDetail`.
- The non-string payload refusal now uses `backend::lang.form.action_prompt_invalid` (en and pl, plus the fixture).
- The `AdminRecordAction` description, the body param and the `AdminActionRequest.Payload` comment are corrected. The toolbar description is reworded so that "payload are all refused" no longer appears anywhere. The pact prompt doc states the 422 toast behaviour.
- admin.json and schema.d.ts are regenerated.
- **Admin SPA (Task 5).**
- Plain keys: `originOf` (`composedPath()[0]`) feeds `isEditableTarget`, which also treats a custom element inside `form`/`[role=form]` as editable.
- `onModifierKey` blurs the focused element before `target.click()` and refocuses it if it is still connected.
- `safeUrl` guards both the `href` and `window.open`.
- A non-2xx GET shows the `form.error_generic` toast and keeps the previous sections. `error: true` renders `myaccount.section_error`.
- The confirm and stale texts use the new `myaccount.action_confirm` and `myaccount.action_stale` keys.
- The dist is rebuilt and committed in the same commit.
## Verification
- `go vet ./...` and `go vet -tags summercms_mcp ./...` are clean.
- `go test -count=1 ./...` is green with Docker available (the cabana Postgres suites ran), and `scripts/check-phase19.7.sh --go` PASSES.
- `go test -count=1 -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` is green.
- `cd examples/hello && go test ./...` is green.
- `npm --prefix admin run typecheck` is clean. `npm --prefix admin run test` passes (100 files, 1544 tests). `npm --prefix admin run build` succeeds.
- `scripts/check-admin-openapi.sh --check` exits 0 and `scripts/check-admin-dist.sh` matches. Both were also run at 44835e4: the dist matched before the SPA commit.
- `scripts/check-phase19.7.sh --docs` (includes `summer docs:build --check`: no problems), `--marker` and `--admin` PASS.
- `git diff --quiet 51d088a -- modules/cabana/account.go` holds.
- go.mod, go.sum and admin/package-lock.json are unchanged.
- An ad-hoc check of `revokedMatch` was run and then deleted, because plan 09 owns the named tests. It confirmed:
- a token event spares another token and an OAuth identity with the same TokenID;
- a client event hits only that client;
- a plain event and a deleted event hit everything.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] `mintAudience` takes the issue time as well as `authTime`**
- **Found during:** Task 1
- **Issue:** With the plan's signature (`authTime` only), `MintAudience` would read `time.Now()` once for `authTime` and `mintAudience` again for `iat`. Across a second boundary, `auth_time` would differ from `iat`.
- **Fix:** `mintAudience(secret, sub, issuerURL, ttl, audience, now, authTime)`. `MintAudience` passes one `now` for both, and refresh passes `time.Now()` and the copied value.
- **Files modified:** modules/bouncer/mint.go, modules/bouncer/refresh.go
- **Commit:** c14238b
**2. [Rule 2 - Missing critical] `/\` treated as protocol-relative**
- **Found during:** Task 4 and Task 5
- **Issue:** Browsers normalise `/\host` to `//host`, so a "single-slash relative path" check alone would let an off-origin URL through.
- **Fix:** Both the server's `safeAccountActionURL` and the SPA's `safeUrl` refuse a leading `/\`.
- **Commits:** 44835e4, 88559d5
**3. [Rule 3 - Blocking] Body param text without inner double quotes**
- **Found during:** Task 4
- **Issue:** swag's quoted `@Param` description cannot hold `{"payload": text}`. Escaped quotes were cut to `Empty object, or {\`.
- **Fix:** The text reads `Empty object, or {payload: text} for a prompted action`. The description keeps the full `{"payload": "<text>"}` form.
- **Commit:** 44835e4
**4. [Scope] Toolbar action description reworded**
- **Issue:** The acceptance grep requires zero "payload are all refused" in admin_openapi.go and admin.json, but the toolbar route's description (which correctly refuses payload) used the same words.
- **Fix:** It now reads "the route refuses record_id, values and payload", with the same meaning.
- **Commit:** 44835e4
**5. [Plan file list] Revocation predicate lives in events.go**
- `sessionRouter.matching` is defined in events.go, not session.go, so `matchingFunc` and `revokedMatch` went there. session.go was not touched.
- docs/backend/users-and-permissions.md also gained the My account URL and error sentences, under the docs rule.
- **Commits:** 24cf6cd, 44835e4, 88559d5
**6. [Rule 3] gofmt of account_tokens.go**
- The file lacked a trailing newline (pre-existing). Running gofmt on the edited file fixed it in 24cf6cd.
### Tests changed for old behaviour
- `modules/cabana/record_action_prompt_postgres_test.go`: the non-string payload refusal now expects "The answer must be text." instead of the hard-coded "The payload must be a string." (B-IN-02). Commit 44835e4.
- `admin/tests/account/AccountContributions.test.ts`: the stale-toast string override moves from `backend::lang.form.action_stale` to `backend::lang.myaccount.action_stale` (B-IN-01). Commit 88559d5.
- No test pinned the per-admin session kill on token revoke or the 500 for a failing `Status`/`Applies`.
## Notes for later plans
- Plan 08: the account-link confirm should call `bouncer.VerifyAuthTime(token, secret, bouncer.AudienceUser)`, refuse `ok == false` and compare `authTime` with the request's `created_at`. User-plugin sign-in mints go through `bouncer.Mint`/`MintAudience` and so already carry the claim.
- Plan 08: a client-specific revocation is fired as `&pact.MCPSubjectChanged{Guard: "<frontend guard>", UserID: id, Kind: pact.MCPSubjectRevoked, ClientID: clientID}`. `UserID` must be set, because the narrowed match requires it.
- Plan 09 (named tests): `TestAuthTime`, revokedMatch and fan-out narrowing, `towel.ClientIP` on the resolver log, `safeAccountActionURL`, section isolation, `originOf`/custom-element editable, blur-before-click with a taglist draft, `safeUrl`.
- The `admin:token:revoke` CLI fires no MCP event, before or after this plan. It is out of scope; plan 09 may record it as deferred.
## Known Stubs
None.
## Threat Flags
None. The new surfaces (the `auth_time` claim, the client address on the context, the narrowed events) are the mitigations T-19.7-51 to T-19.7-56 in the plan's threat register.
## Self-Check: PASSED
- FOUND: modules/bouncer/auth_time_smoke_test.go, .planning/notes/2026-10-23-frontend-token-scopes.md
- FOUND: c14238b, 9eedfbb, 0ec00f7, 24cf6cd, 44835e4, 88559d5
- The only deleted files are the replaced hashed assets under modules/boardwalk/dist (a rebuild)

View File

@@ -0,0 +1,601 @@
---
phase: 19.7-generic-mcp-module
plan: 08
type: execute
wave: 3
depends_on: ["19.7-06", "19.7-07"]
gap_closure: true
files_modified:
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go
- ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/console/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/models/organisation.go
- ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml
- ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/mcp_store.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/vue-demo-app
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/plugins/golem15/oauthserver
- ../sm-demo-app/plugins/golem15/journal
autonomous: true
requirements: [SC-4, D-03, D-05, D-07, D-08, D-09, D-19, D-20, D-21, D-26, D-27, D-28, E-1, E-2]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 4
confidence: low
must_haves:
truths:
- "The account link is confirmed only by a session whose auth_time is at or after the link request: a token refreshed after the request is refused with 409 stale_session, a token without auth_time is refused the same way, and a fresh sign-in after the request links (C-CR-02 plugin side, D-05)"
- "The link code never reaches a server log: My account answers <frontend_url><page_path>#code=<code>, the site page reads the fragment, keeps the code in sessionStorage across the sign-in, shows the request through POST /_user/api/v1/backend-link/show with the code in the body, and shows which frontend account is signed in before the confirm button (C-WR-01, C-IN-06, D-05)"
- "Banning a user, resetting their password, an admin setting their password, or their own password change revokes their MCP access and refresh tokens created before the cut-off and ends their MCP sessions; the MCP verifier and the MCP refresh rotation refuse a banned user and any token created before tokens_valid_after plus one second (the stored cutoff is whole seconds, so a token from earlier in the cutoff's second is refused too), and the revoke uses the untruncated revocation instant; a banned user's link answers no backend user (C-CR-01, D-08, D-27)"
- "Creating or removing a frontend user's backend link revokes that user's MCP grants that carry the backend scope, so a backend grant never runs with the reach of an administrator the user did not consent to (C-WR-02, D-21)"
- "A plugin declares member token scopes with pact.HasFrontendTokenScopes; POST /_user/api/v1/tokens and MintAPITokenFor accept them for the application that declares them (a second application in the same process refuses them), the legacy MintAPIToken accepts them through a process-wide fallback, middleware.Scope and APIToken.HasScope check them unchanged, GET /tokens lists them, GET /_user/api/v1/tokens/scopes lists the built-ins and the declared scopes with translated labels, and a duplicate, a built-in redeclared or a bad name fails boot (E-2)"
- "Organisations have a logo image (attachOne `logo`) editable in the backend Organisations form, and applications get the logo for their organisation payloads through exported helpers using the same keys the user payload uses for avatars (E-1)"
- "Revoking one personal token or one OAuth authorization ends only the MCP sessions it opened; a personal token used at /mcp records last_used_ip; Journal's draft_post prompt is hidden from read-only grants; nginx accepts MCP bodies up to pier's 4 MiB cap (C-IN-01..C-IN-05)"
- "Every plugin change is additive: no exported identifier, route, column, payload field or migration removed or changed; plugin go.mod require lines are unchanged"
artifacts:
- path: "../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go"
provides: "auth_time-based confirm and the POST show route"
contains: "VerifyAuthTime"
- path: "../sm-demo-app/plugins/golem15/user/classes/access_revoked.go"
provides: "AccessRevokedEvent firing helper"
contains: "func FireAccessRevoked("
- path: "../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go"
provides: "the member token scope catalog"
contains: "HasFrontendTokenScopes"
- path: "../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go"
provides: "OrganisationLogoURL and OrganisationLogoFields"
contains: "func OrganisationLogoFields("
- path: "../sm-oauthserver-plugin/classes/cascade.go"
provides: "revocation on access-revoked and on link changes"
contains: "AccessRevokedEvent"
- path: "../sm-demo-app/vue-demo-app/app/lib/linkCode.ts"
provides: "fragment parsing and sessionStorage key for the link page"
contains: "readLinkCode"
key_links:
- from: "../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go"
to: "summercms.go modules/bouncer/jwt.go"
via: "bouncer.VerifyAuthTime replaces the iat read"
pattern: "VerifyAuthTime"
- from: "../sm-oauthserver-plugin/plugin.go"
to: "../sm-demo-app/plugins/golem15/user/classes/events.go"
via: "Listen on *userclasses.AccessRevokedEvent, BackendLinkedEvent and BackendUnlinkedEvent"
pattern: "AccessRevokedEvent"
- from: "../sm-demo-app/plugins/golem15/user/plugin.go"
to: "../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go"
via: "Boot compiles the catalog from party.Activated and publishes it; a bad declaration fails boot"
pattern: "CompileAPITokenScopes"
---
<objective>
Close lane C of the phase code review and both sm-servers-app escalations, on the plugin side, as additive changes against the local framework from plans 06 and 07.
| Repository | Changes |
|------------|---------|
| sm-user-plugin (checkout `../sm-demo-app/plugins/golem15/user`) | C-CR-01 user side; C-CR-02 plugin side (compare against `auth_time`); C-WR-01; the C-WR-02 link events; E-1 organisation logo; E-2 member token scopes; C-IN-02, C-IN-04, C-IN-05, C-IN-06 |
| `../sm-oauthserver-plugin` | C-CR-01 (verifier and refresh refusals, revoke on ban, reset or password change); C-WR-02 (revoke backend grants on link and unlink); C-IN-02 (client-specific revoke event); C-IN-05 |
| `../sm-journal-plugin` | C-IN-01 (`draft_post` marked `Writes`) |
| `../sm-demo-app` | nginx body cap (C-IN-03); the vue-demo-app `/link-admin` page (code in the fragment, POST show, signed-in account shown); plugin submodule checkouts synced to the local commits for verification |
**Planner decisions recorded here (P-34..P-39):**
- **P-34 (link page and proof, C-CR-02 and C-WR-01):**
- My account answers `golem15.user.frontend_url` (else `app.url`) + `golem15.user.backend_links.page_path` (new key, default `/link-admin`, C-IN-06) + `#code=<code>`.
- New route `POST /_user/api/v1/backend-link/show` with body `{"code"}` and the same answers as the GET route. The GET route stays for compatibility (core-plugin additive rule); the README marks it deprecated because the code in its path reaches access logs.
- The confirm reads the session token with `bouncer.VerifyAuthTime(..., bouncer.AudienceUser)` and passes `auth_time` to `ConfirmLinkRequest`. The signature is unchanged; the parameter is renamed `sessionAuthTime` in the doc. A token without `auth_time` answers 409 `stale_session`.
- **P-35 (access revocation, C-CR-01):**
- New `classes.AccessRevokedEvent{UserIDs []uint, Before time.Time, Reason string, Tx *gorm.DB}`.
- New `classes.FireAccessRevoked(ctx, app, tx, userIDs, before, reason) error`. It fires the event in `tx` (a listener error rolls the write back) and one `pact.MCPSubjectRevoked` per user after commit.
- Called by the plugin's ban action, password reset, an admin setting a password, and the self-service password change.
- The ban action is a wrapper around `BanUsers`, whose exported signature is unchanged. Inside the action's transaction it reads `ThrottleStates` for the selected ids before and after `BanUsers`. The newly banned ids are those banned after but not before, and only they are passed to `FireAccessRevoked`.
- `Before` is the untruncated instant of the revocation (`time.Now()` taken at the write). The stored `tokens_valid_after` keeps its whole-second shape (for example `now.Truncate(time.Second)` in `ResetPassword`). The self-service change keeps its cutoff of iat minus one second and passes `cutoff.Add(time.Second)`, the current session's sign-in second, so tokens issued since that sign-in survive.
- `RevokeUserAccess`, `BanUsers` and `DeactivateUsers` keep their signatures. The README tells applications that call them directly to call `FireAccessRevoked` too. Deactivation keeps its `DeactivatingEvent` cascade.
- oauthserver revokes MCP-client access and refresh tokens with `created_at < Before`. Both the verifier and the MCP refresh rotation also refuse a banned user, and refuse a token with `created_at` before `tokens_valid_after + 1s` (`CreatedAt.Before(cutoff.Add(time.Second))`).
- Why a second is added: the stored cutoff is truncated to whole seconds while `created_at` is not. A token created at 12:00:05.300, before a reset at 12:00:05.700 that stores 12:00:05, is therefore refused by the verifier even without the revoke listener.
- Why `!CreatedAt.After(cutoff)` alone is not enough: it lets that token through.
- The cost is a token legitimately minted in the same second right after a reset. An OAuth consent round trip cannot complete that fast, so the cost is accepted.
- The revocation sits behind one oauthserver function, `revokeUserGrants(ctx, tx, userIDs []uint, before time.Time, kinds []string) error`. `OnAccessRevoked` calls it with both the MCP and the OIDC client kinds: the user decided on 2026-10-23 that a ban or a password reset ends every OAuth grant of that user, MCP agents and OIDC sign-ins alike. Link changes (C-WR-02) still revoke only `backend`-scoped MCP grants.
- **P-36 (backend grants follow the link, C-WR-02):** oauthserver listens to `BackendLinkedEvent` and `BackendUnlinkedEvent`. In the event's transaction it revokes the frontend user's unrevoked MCP access and refresh tokens whose scopes contain `backend`; after commit it fires `MCPSubjectRevoked` with each affected `ClientID`.
- `BackendUnlinkedEvent` already fires on every unlink the plugin performs: My account, the Administrators screen, and the deletion or deactivation of the frontend user (`dropUserLink` → `RemoveLink`, documented in `classes/events.go`).
- The link event is still handled for one silent path: deleting the backend administrator in cabana removes the link row through the foreign key's `ON DELETE CASCADE`, and no event fires. Revoking on every new link means a grant left over from that path cannot run with the reach of a later admin, so a backend grant only ever runs with the reach of the admin shown at its consent.
- This is the review's first option. The second option, storing the consented admin on the grant, needs a framework field and an oauthserver migration and is not taken.
- **P-37 (member token scopes, E-2):**
- `classes.APITokenScope{Name, Label, Description, PluginID string; BuiltIn bool}` and `classes.APITokenScopeCatalog` (`Scopes()`, `Allowed(name)`).
- `classes.CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error)` validates with `pact.ValidTokenScopeName`. It refuses a built-in name (`read`, `write`, `ai`), a duplicate across plugins, an empty label or description, and a `::` label or description whose phrase key is missing (checked in Boot against the translator).
- Boot compiles from `party.Activated`, publishes the catalog, and fails boot on an error.
- `classes.APITokenScopesFor(app)` returns the published catalog or the built-ins.
- **Validation is scoped to the application.** New `classes.MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error)` checks scopes against `APITokenScopesFor(app)`. The HTTP mint route and the plugin's console command use it, and the README tells applications to use it.
- The legacy `MintAPIToken` keeps its signature and keeps working for existing callers (for example an application's own token manager). It accepts the built-ins plus a process-wide fallback set: every name a booted catalog in this process declared. Production runs one application per process, so the set equals that application's catalog. The README documents the difference.
- `classes.ResetDeclaredAPITokenScopes()` clears the fallback set. It is exported as a test seam (documented "tests only"), because the plugin's tests live outside the classes package.
- The HTTP mint route checks the application's own catalog, so an undeclared scope answers the existing 422 text "The selected scope is invalid.". In a test binary that boots two applications, app B refuses app A's scope.
- Discovery is `GET /_user/api/v1/tokens/scopes` behind `jwt.auth`, answering `{"data":[{"name","label","description","built_in"}]}` with built-ins first. Labels are translated with the plugin's `appTranslator` for the request locale.
- `AllowedAPITokenScopes` stays the built-in list.
- **P-38 (organisation logo, E-1):** a second public attachOne `logo` on `Organisation` (`OrganisationLogoField`); the existing `avatar` stays, for PHP parity and additivity.
- Attachments live in `system_files`, so there is no migration.
- The Organisations form gains an image `fileupload` field `logo`.
- The plugin serves no frontend organisation payload today, so it ships two helpers for the applications that build one:
- `classes.OrganisationLogoURL(ctx, app, org, size) (string, bool, error)`;
- `classes.OrganisationLogoFields(ctx, app, org) (map[string]any, error)`, answering `has_logo`, `logo` (disk name or nil) and `logo_url` (a 256 px thumbnail or nil), the keys the user payload uses for avatars.
- Reversibility costly: the relation name is stored in `system_files.field`.
- **P-39 (local verification wiring):** oauthserver's git-ignored `go.work` points `sm-user-plugin` at `../sm-demo-app/plugins/golem15/user`. Today it points at another application's checkout, which lacks the new exports. The demo's `plugins/golem15/oauthserver` and `plugins/golem15/journal` submodule checkouts are moved, detached, to this plan's local plugin commits so the demo's end-to-end test runs them.
- None of this is committed: the pointer bumps in sm-demo-app happen after the plugin tags, per the release order.
- The vue-demo-app pointer is a normal local commit in sm-demo-app, as in plan 04. vue-demo-app is pushed before sm-demo-app when the user releases.
**Commits are self-contained:** each task's commits build and pass the repository's `go vet` and `-short` tests on their own. No commit leaves a repository relying on a later task's commit, and a cross-repository dependency is always committed in the order user plugin → oauthserver → demo.
Purpose: SC-4's identity chain holds under ban, password reset, refresh and re-linking; sm-servers-app gets E-1 and E-2; plan 09 can pin all of it.
Output: local commits in sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, vue-demo-app and sm-demo-app; three smoke tests.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md
@.planning/phases/19.7-generic-mcp-module/19.7-03-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-04-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md
@.planning/notes/2026-10-23-frontend-token-scopes.md
@.planning/notes/core-plugins-own-repos.md
@CLAUDE.md
</context>
<project_rules>
- Core-plugin rule: sm-user-plugin is used by many applications, and oauthserver and Journal are shared. Every change is additive:
- no exported func, method or type removed or re-signed (the gate's additive check against 930b158 and v0.1.9 for the user plugin, d31f8e7 for oauthserver, ffa2c8f for Journal);
- no route, column, migration id, config key, permission code or payload field removed or renamed;
- existing error bodies stay word for word.
- Plugin go.mod `require` lines stay as they are (summercms v0.4.4, sm-user-plugin v0.1.10 in oauthserver). The local framework comes from each repository's `replace ../summercms.go` or go.work. Never run `go get` for the framework or a plugin.
- The user plugin is built and tested with `GOWORK=off`; its go.mod replace points at `../../../../summercms.go`. oauthserver uses its git-ignored go.work (Task 2 retargets it). Journal uses its tracked go.work, which is left unchanged.
- Docker-backed tests: if the socket is denied although the user is in the docker group, run them through `sudo -u golem` (CLAUDE.md). Postgres tests never use `t.Parallel`; they seed unique rows and clean up.
- Unit tests are plan 09's job. This plan carries three smokes (`TestBackendLinkAuthTimeSmoke`, `TestMCPRevocationSmoke`, `TestMemberTokenScopesSmoke`) and keeps every existing suite green: `TestBackendLink`, `TestUserMCPGuard`, `TestMCPTokenVerifier`, `TestMCPConsent`, `TestJournalMCP`, `TestMCPEndToEnd`. A test pinning the old behaviour (for example the iat-based confirm) changes in the fix commit and is named in the SUMMARY.
- Each plugin's README changes in the same commit as its exported API, config keys or routes.
- sm-demo-app has uncommitted changes from other sessions (`main.go`, the `plugins/golem15/user` pointer from plan 05): run `git -C ../sm-demo-app status --short` first and never stage either. Stage by explicit path in every repository. One logical change per commit, no co-author tags. Order: plugin repo commits first, then sm-demo-app commits.
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the account link proof end to end — My account answers a fragment link, the site page shows the request through POST and the signed-in account, and only a session signed in after the request (auth_time) confirms it</name>
<precondition>Plan 07's `bouncer.VerifyAuthTime` exists in the local framework (`grep -c "func VerifyAuthTime(" modules/bouncer/jwt.go` prints 1), and a Docker daemon answers (`docker info`, directly or through `sudo -u golem`).</precondition>
<files>../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go, ../sm-demo-app/plugins/golem15/user/classes/backend_links.go, ../sm-demo-app/plugins/golem15/user/account_contributions.go, ../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/config/config.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go, ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue, ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts, ../sm-demo-app/vue-demo-app/i18n/locales/en.json, ../sm-demo-app/vue-demo-app/i18n/locales/pl.json, ../sm-demo-app/vue-demo-app</files>
<read_first>
- 19.7-REVIEW.md, sections C-CR-02, C-WR-01 and C-IN-06; 19.7-07-SUMMARY.md (`VerifyAuthTime`'s final signature)
- U controllers/backend_link_controller.go (`BackendLinkShow`, `BackendLinkConfirm`, `backendLinks`, `isLinkGone`); U classes/backend_links.go (`LinkRequestByCode`, `ConfirmLinkRequest` and its doc, `requestUsable`); U account_contributions.go (`startFrontendLink`); U routes.go (the `/backend-link/*` routes and `g.Where("code", ...)`); U config/config.yaml (`frontend_url`, `backend_links`); U classes `FrontendURL`
- U backend_link_smoke_test.go and backend_link_test.go (the Postgres harness, `startLink`/`confirmLink`, how a session token is minted for a user)
- V app/pages/link-admin.vue, app/composables/useDemoAuth.ts (token cookie, logout), app/pages/login.vue (how `next` is followed), i18n/locales/en.json and pl.json (`linkAdmin.*`)
- U README.md, the account link section
</read_first>
<action>
Implement P-34.
**U backend_link_controller.go:**
- `BackendLinkConfirm` replaces the `VerifyClaims` iat read with `bouncer.VerifyAuthTime(classes.SessionToken(r), secret, bouncer.AudienceUser)`. A subject mismatch or a verify error stays 401. `ok == false` answers the existing 409 `stale_session` body. Pass `authTime` to `ConfirmLinkRequest`.
- Add `BackendLinkShowPost(app)`: it reads `{"code"}` with `readFields` and answers exactly like `BackendLinkShow`: the same 404 for an unknown, used or expired code, and the same data shape. Both show handlers set `Cache-Control: no-store, private`.
**U classes/backend_links.go:** rename the `ConfirmLinkRequest` parameter to `sessionAuthTime` and rewrite its doc. The proof is the sign-in time (`auth_time`), which a refresh never renews; the comparison stays in whole seconds against `created_at`. Keep the signature and types.
**U routes.go:** add `g.Post("/backend-link/show", controllers.BackendLinkShowPost(p.app))` in the same group. The GET route stays.
**U account_contributions.go:**
- `startFrontendLink` answers `classes.FrontendURL(p.app) + pagePath + "#code=" + plain`. Rewrite its doc comment to describe the fragment link as well; the comment must not show the old query-string form.
- `pagePath` comes from the new config key `golem15.user.backend_links.page_path` (default `/link-admin`; a value without a leading `/` gets one) through a small helper in classes next to `FrontendURL`.
- Add the key to config/config.yaml under `backend_links`.
**U README.md:** the link section describes:
- the fragment URL;
- the POST show route;
- the GET show route, kept for compatibility (its path puts the code in access logs, so pages use POST);
- `page_path`;
- the `auth_time` rule, with an upgrade note: session tokens issued before this release carry no `auth_time`, so a frontend user signed in before the upgrade gets `stale_session` and must sign in again before linking. A refresh does not help, because it carries the absence forward.
**New smoke `backend_link_auth_time_smoke_test.go`** (Postgres, created only when not `-short`) holds `TestBackendLinkAuthTimeSmoke`:
1. A user signs in (minted token).
2. The admin creates a request.
3. Refreshing the old token through `POST /_user/api/v1/refresh` and confirming with the refreshed token answers 409 `stale_session`.
4. A login after the request confirms with 200.
5. `POST /backend-link/show` with the code in the body answers the admin's login.
6. The My account action's URL contains `/link-admin#code=` and no query string.
**V app/lib/linkCode.ts** (pure, unit-testable in plan 09):
- `LINK_CODE_KEY = 'link-admin-code'`;
- `readLinkCode(hash: string): string`, returning the 64-hex value of a `#code=` fragment or '';
- `isLinkCode(s: string): boolean`.
**V app/pages/link-admin.vue:**
- In `onMounted` (client only), read the fragment. If it holds a code, store it under `LINK_CODE_KEY` in `sessionStorage` and drop the fragment with `history.replaceState`. Otherwise take the stored code.
- Load the request with `$fetch('/_user/api/v1/backend-link/show', { method: 'POST', body: { code } })`.
- When signed in, load `GET /_user/api/v1/fetch` with the bearer and show `linkAdmin.signedInAs` (the email) above the confirm button, with a `linkAdmin.notYou` action that calls `logout()` and shows the sign-in link.
- The sign-in link is `/login?next=/link-admin`; the code stays in sessionStorage, never in a URL.
- Remove the stored code on success and when the request is gone.
- The route's query parameter is no longer read, and the page's header comment describes the fragment flow.
- Add `linkAdmin.signedInAs` and `linkAdmin.notYou` to en.json and pl.json.
- Reword the existing `linkAdmin.signInAgain` text (shown after a 409 `stale_session` or a 401) in both locales. It says the link needs a sign-in made after the request, and that a session started before the site's last update, or before the link was requested, must sign in again. For example, en: "Linking needs a fresh sign-in. Sessions started before the link was requested, or before the site was last updated, have to sign in again." Add pl to match.
Run `pnpm --dir ../sm-demo-app/vue-demo-app run test` (the existing lib tests stay green).
**Commits:**
1. The user plugin commit.
2. The vue-demo-app commit in its submodule checkout.
3. In sm-demo-app, a separate local commit `chore: bump vue-demo-app (link page: code in the fragment, POST show)` staging only `vue-demo-app`.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^(TestBackendLinkAuthTimeSmoke|TestBackendLink|TestBackendLinkSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && pnpm --dir ../sm-demo-app/vue-demo-app run test</automated>
<fails_when>non-zero exit, "FAIL" in the output, or fewer than 3 top-level PASS lines (a SKIP because Docker is unreachable counts as not passing: run on a Docker host, through sudo -u golem if needed)</fails_when>
</verify>
<acceptance_criteria>
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go | grep -c "bouncer.VerifyAuthTime("` prints 1 and the same filter for `bouncer.VerifyClaims(` prints 0
- `grep -c 'g.Post("/backend-link/show"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1 and `grep -c 'g.Get("/backend-link/{code}"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1 (kept)
- `grep -c '#code=' ../sm-demo-app/plugins/golem15/user/account_contributions.go` prints 1 and `grep -c '?code=' ../sm-demo-app/plugins/golem15/user/account_contributions.go` prints 0
- `grep -c "page_path" ../sm-demo-app/plugins/golem15/user/config/config.yaml ../sm-demo-app/plugins/golem15/user/README.md` prints at least 1 per file
- `grep -c "route.query" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints 0, `grep -c "sessionStorage" ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue` prints at least 1, and `grep -c "signedInAs" ../sm-demo-app/vue-demo-app/i18n/locales/en.json ../sm-demo-app/vue-demo-app/i18n/locales/pl.json` prints 1 per file
- `git -C ../sm-demo-app log -1 --name-only --format=%s | grep -c "^vue-demo-app$"` prints 1 and the same commit lists neither `main.go` nor `plugins/golem15/user`
</acceptance_criteria>
<done>The link proof rests on the sign-in time, the code never appears in a URL the server sees, and the person confirming sees which account they are linking; the smoke proves refresh no longer passes as a fresh sign-in.</done>
</task>
<task type="auto">
<name>Task 2: Grant revocation chain: ban, password reset or change revoke MCP grants and end sessions; the verifier and refresh refuse banned and pre-cutoff tokens; backend grants follow the link; token- and client-specific revoke events; last_used_ip on /mcp; deduplicated helpers</name>
<reversibility rating="reversible">New events and listeners plus stricter refusals; nothing stored changes shape.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/classes/events.go, ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go, ../sm-demo-app/plugins/golem15/user/classes/backend_links.go, ../sm-demo-app/plugins/golem15/user/classes/subject_events.go, ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go, ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go, ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go, ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go, ../sm-demo-app/plugins/golem15/user/account_contributions.go, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/classes/mcp_store.go, ../sm-oauthserver-plugin/classes/cascade.go, ../sm-oauthserver-plugin/classes/subject_events.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/plugin.go, ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go, ../sm-oauthserver-plugin/README.md</files>
<read_first>
- 19.7-REVIEW.md, sections C-CR-01, C-WR-02, C-IN-02, C-IN-04 and C-IN-05
- U classes/admin_actions.go (`ThrottleStates`, `RevokeUserAccess`, `BanUsers`, `DeactivateUsers`); U classes/events.go (event structs, the `*EventName` constants, `BackendLinkedEvent`, `BackendUnlinkedEvent`); U classes/subject_events.go; U classes/backend_links.go (`LinkedBackendUser` SQL at ~385-410, `insertLink`, `RemoveLink`, `requestUsable`); U models/backend_link_request.go (`IsUsableAt`)
- U controllers/api_controller.go (`ResetPassword` at ~440-460, `ChangePassword` at ~700-725); U controllers/users_admin_controller.go (password store at ~236-252, the bulk actions at ~625-660); U controllers/api_tokens.go (`APITokenDestroy`); U classes/mcp_guard.go (`MCPResolvePersonalToken`); U middleware/api_token.go (how `last_used_ip` is stamped)
- O classes/mcp_verifier.go (`MCPVerifyToken`), classes/mcp_store.go (`mcpTx.refresh` at ~286, `revokeRefreshLineage`), classes/cascade.go (`Cascade`, `purge`), classes/subject_events.go, controllers/authorizations.go (revoke handler at ~110-125), plugin.go (listener registration at ~99-103), models/access_token.go and refresh_token.go (Scopes, CreatedAt, RevokedAt), models/client.go (`ClientKindMCP`), mcp_verifier_smoke_test.go and harness_test.go (test shapes)
- summercms.go modules/towel/context.go (`ClientIP`) and modules/pact/mcp.go (`MCPSubjectChanged.TokenID`, `ClientID`) from plan 07
- `cat ../sm-oauthserver-plugin/go.work` (the current `sm-user-plugin` replace target)
</read_first>
<action>
**Step 0, local environment (P-39).** Edit the git-ignored `../sm-oauthserver-plugin/go.work` so its `sm-user-plugin` replace points at `../sm-demo-app/plugins/golem15/user`. Record the previous target in the SUMMARY. Never commit go.work.
**U, P-35:**
- In classes/events.go, add `AccessRevokedEvent{UserIDs []uint; Before time.Time; Reason string; Tx *gorm.DB}` and `AccessRevokedEventName = "user.AccessRevoked"`, documented like the other events.
- New file classes/access_revoked.go holds `FireAccessRevoked(ctx context.Context, app *backpack.App, tx *gorm.DB, userIDs []uint, before time.Time, reason string) error`. It fires the event (a listener error is returned, so the caller's transaction rolls back), then for each id calls `FireSubjectChangedAfterCommit` with `MCPSubjectRevoked`. A nil app or bus, or an empty id list, is a no-op.
- Callers:
- **Ban.** `usersAdminController` gets a `banUsers` wrapper used by the `ban` bulk action. Inside the action's transaction `tx`:
1. read `classes.ThrottleStates(ctx, tx, ids)` for the selected ids;
2. call `classes.BanUsers(ctx, tx, users)`;
3. read `ThrottleStates` again;
4. take the newly banned ids (banned after, not before), and only when that list is non-empty call `FireAccessRevoked(ctx, app, tx, newly, time.Now(), "ban")`.
- **Password reset.** `ResetPassword` keeps storing `tokens_valid_after = now.Truncate(time.Second)`, so the column keeps its shape. It passes the untruncated `now` as `before`, reason `password_reset`. The handler runs outside a transaction today: wrap the update and the event in `lagoon.Transaction`, so a listener failure leaves the password unchanged.
- **Self-service change.** `ChangePassword` keeps `cutoff = iat - 1s` in the column and passes `cutoff.Add(time.Second)` (the current session's sign-in second) as `before`, reason `password_changed`. Tokens issued since that sign-in survive, as the session JWT does.
- **Admin-set password.** The admin password store keeps the truncated value in the column and passes the untruncated `time.Now()` as `before`, reason `password_set_by_admin`, in the save's transaction (`requestDB` or the hook's tx).
- `LinkedBackendUser` adds `AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned)`. The columns are `user_throttle.user_id` and `user_throttle.is_banned` (models/throttle.go).
**U, C-IN-02, C-IN-04, C-IN-05:**
- `APITokenDestroy` fires `MCPSubjectRevoked` with `TokenID: token.ID` through a new exported helper pair in classes/subject_events.go: `FireSubjectEvent(ctx, app, ev pact.MCPSubjectChanged)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`. The existing helpers become wrappers with their signatures kept.
- `MCPResolvePersonalToken` also stamps `last_used_ip` from `towel.ClientIP(ctx)` when present, in the same `UpdateColumns` call as `last_used_at`.
- `requestUsable` keeps its error split but uses `req.IsUsableAt` for the combined check.
- Add an exported `classes.BackendLinksFor(app) *BackendLinks`, used by both account_contributions.go and the controller instead of their duplicate lookups.
- Confirm, in code and README, that `BackendLinkedEvent` and `BackendUnlinkedEvent` fire on every link and unlink path, with `Link.UserID` set. These are the C-WR-02 events oauthserver listens to.
- The README gains the access-revocation section: when it fires, the event, and what applications calling `RevokeUserAccess` or `BanUsers` directly must add.
**O:**
- Add one unexported helper, `issuedBeforeCutoff(createdAt *time.Time, cutoff time.Time) bool`. It answers true when the cutoff is set and `createdAt` is nil or `createdAt.Before(cutoff.Add(time.Second))`. Reason: the stored cutoff is truncated to whole seconds, so a token created earlier in the cutoff's second must be refused too (P-35). Both checks below use it.
- classes/mcp_verifier.go: after loading the principal, refuse with `pact.ErrMCPTokenInvalid` when `issuedBeforeCutoff(token.CreatedAt, principal.TokensValidAfter)`, or when `userclasses.ThrottleStates` reports the user banned. A database error is returned as an error.
- classes/mcp_store.go `mcpTx.refresh`: apply the same three refusals (user gone through `PrincipalByID`, banned, `issuedBeforeCutoff` on the refresh token) by answering the store's existing not-found result, which wristband maps to `invalid_grant`.
- classes/cascade.go:
- Add the single revocation function `revokeUserGrants(ctx context.Context, tx *gorm.DB, userIDs []uint, before time.Time, kinds []string) error`. It sets `revoked_at` on the users' unrevoked access and refresh tokens created before `before`, whose client kind is in `kinds`, and returns the distinct client ids it touched.
- `OnAccessRevoked(ctx, *userclasses.AccessRevokedEvent)` calls it in `ev.Tx` with `ev.Before` and the MCP and OIDC client kinds (user decision 2026-10-23: a ban or reset ends every OAuth grant). Before revoking, check how OIDC grants are stored (client kind constant, consent rows, access and refresh tokens) and revoke the tokens and the consent row so the OIDC client must ask again; record the exact rows touched in the SUMMARY.
- `OnBackendLinkChanged(ctx, userID uint, tx *gorm.DB)` sets `revoked_at` on the user's unrevoked MCP access and refresh tokens whose scopes contain `backend`, then fires `MCPSubjectRevoked` with each distinct `ClientID` after commit.
- Two adapters: `OnBackendLinked(ctx, *userclasses.BackendLinkedEvent)` and `OnBackendUnlinked(ctx, *userclasses.BackendUnlinkedEvent)`.
- plugin.go: register the three listeners next to the existing two.
- controllers/authorizations.go: the revoke fires with `ClientID: clientID`.
- classes/subject_events.go: delegate to the user plugin's helpers. Keep oauthserver's exported names as thin wrappers (additive rule).
- README: the revocation behaviour.
**New smoke `../sm-oauthserver-plugin/mcp_revocation_smoke_test.go`** (Postgres) holds `TestMCPRevocationSmoke`:
1. Mint an MCP access and refresh token for a user.
2. `BanUsers` plus `FireAccessRevoked` in one transaction.
3. The verifier answers `ErrMCPTokenInvalid`.
4. Both rows have `revoked_at`.
5. A refresh is refused.
6. A second user linked to an admin with a `backend` grant loses it on `RemoveLink`, and keeps a grant without `backend`.
**Commits:** the user plugin first, then oauthserver.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^(TestBackendLink|TestUserMCPGuard|TestBackendLinkAuthTimeSmoke)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go -C ../sm-oauthserver-plugin vet ./... && go -C ../sm-oauthserver-plugin test -count=1 -short ./... && go -C ../sm-oauthserver-plugin test -count=1 -run '^(TestMCPRevocationSmoke|TestMCPTokenVerifier|TestMCPConsent)$' -v . 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
<fails_when>non-zero exit, "FAIL" in the output, or a PASS count other than 3 for either repository (a SKIP without Docker counts as not passing)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "type AccessRevokedEvent struct" ../sm-demo-app/plugins/golem15/user/classes/events.go` prints 1 and `grep -c "func FireAccessRevoked(" ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go` prints 1
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go | grep -c "FireAccessRevoked("` prints at least 2 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go | grep -c "FireAccessRevoked("` prints at least 2
- `grep -c "is_banned" ../sm-demo-app/plugins/golem15/user/classes/backend_links.go` prints at least 1 (`user_throttle.is_banned`, models/throttle.go)
- `grep -v '^\s*//' ../sm-oauthserver-plugin/classes/mcp_verifier.go | grep -c "ThrottleStates(\|TokensValidAfter"` prints at least 2
- `grep -c "AccessRevokedEvent\|BackendLinkedEvent\|BackendUnlinkedEvent" ../sm-oauthserver-plugin/plugin.go` prints at least 3
- `grep -v '^\s*//' ../sm-oauthserver-plugin/controllers/authorizations.go | grep -c "ClientID"` prints at least 1 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go | grep -c "last_used_ip"` prints at least 1
- `git -C ../sm-oauthserver-plugin status --short go.work | wc -l` prints 0 (go.work stays ignored and uncommitted)
- `grep -c "func revokeUserGrants(\|func issuedBeforeCutoff(" ../sm-oauthserver-plugin/classes/*.go | awk -F: '{s+=$2} END {print s}'` prints 2, and `grep -v '^\s*//' ../sm-oauthserver-plugin/classes/mcp_verifier.go ../sm-oauthserver-plugin/classes/mcp_store.go | grep -c "issuedBeforeCutoff("` prints at least 2
- `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go | grep -c "ThrottleStates("` prints at least 2 (before and after BanUsers)
</acceptance_criteria>
<done>A banned or password-reset user's MCP grants stop working at once and their sessions end, refresh cannot revive them, a banned user's link stops resolving, a backend grant cannot outlive or be re-targeted by a link change, and single-token or single-client revocations end only what they opened.</done>
</task>
<task type="auto">
<name>Task 3: E-2 member token scopes: catalog from pact.HasFrontendTokenScopes, boot validation, mint, check, list and discovery</name>
<reversibility rating="costly">Declared scope names are stored on `user_api_tokens` rows and the discovery route is a published contract of a core plugin.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go, ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go, ../sm-demo-app/plugins/golem15/user/console/api_tokens.go, ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go, ../sm-demo-app/plugins/golem15/user/routes.go, ../sm-demo-app/plugins/golem15/user/plugin.go, ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml, ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go</files>
<read_first>
- 19.7-GAP-INPUTS.md, section E-2 (the ask, "Not asked for", acceptance); .planning/notes/2026-10-23-frontend-token-scopes.md (decision P-30)
- summercms.go modules/pact/capabilities.go (`FrontendTokenScope`, `HasFrontendTokenScopes`, `ValidTokenScopeName`) and modules/cabana/token_scopes.go (`validatePluginTokenScope`, `validateTokenScopeMessageKeys`: the validation style to mirror)
- U classes/api_tokens.go (`AllowedAPITokenScopes`, `DefaultAPITokenScopes`, `MintAPIToken`), controllers/api_tokens.go (`APITokenStore`, `APITokenIndex`, `apiTokenScopes`, `apiTokenJSON`), middleware/api_token.go (`Scope`), models/api_token.go (`HasScope`), routes.go (the `/tokens` routes), plugin.go (`Boot`, `bootMCPGuard` as the publish pattern), classes/frontend_permission_catalog.go (`party.Activated` lookup), controllers/app_access.go (`appTranslator`)
- U api_tokens_test.go and api_tokens_edge_test.go (harness for token routes)
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go (an application calling `MintAPIToken` directly; its call must keep compiling unchanged) and ../fonoteka.go/go.work (its workspace `use`s its own user checkout; the verify builds it through a scratch copy that swaps in this checkout; fonoteka's own go.work is never edited)
</read_first>
<action>
Implement P-37 in a new file classes/api_token_scopes.go:
- `APITokenScope` (Name, Label, Description, PluginID, BuiltIn).
- `APITokenScopeCatalog` with `Scopes() []APITokenScope` (a copy, built-ins first, then declarations in activation order) and `Allowed(name string) bool`.
- `CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error)`. The built-ins `read`, `write` and `ai` carry the labels `golem15.user::lang.api_tokens.scope_<name>` and `scope_<name>_description`. Each `pact.HasFrontendTokenScopes` declaration is checked:
- the name is trimmed and must pass `pact.ValidTokenScopeName`;
- it must not be a built-in;
- it must not repeat another plugin's name; the error names both plugins, like cabana's;
- label and description must be non-empty.
- Every accepted declared name is added to an unexported process-wide set guarded by a `sync.RWMutex`.
- `APITokenScopesFor(app *backpack.App) *APITokenScopeCatalog` returns the published catalog or a built-ins-only one.
- Add `MintAPITokenFor(app *backpack.App, prefix string, userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.APIToken, error)`. It checks scopes with `APITokenScopesFor(app).Allowed` and shares the record-building body with `MintAPIToken` through an unexported helper that takes the allow-check as a func.
- `MintAPIToken` keeps its signature, dedupe and default. Its check becomes "built-in, or in the process-wide declared set" (the legacy fallback; its doc points new code to `MintAPITokenFor`).
- Add `ResetDeclaredAPITokenScopes()`, which clears the process-wide set. Document it as a test seam that production never calls.
- The plugin's console token command (console/api_tokens.go) switches to `MintAPITokenFor` with its app.
- `AllowedAPITokenScopes` stays, documented as the built-ins.
In plugin.go `Boot`:
- compile from `app.Lookup[party.Activated]()`;
- check every `::` label and description against the translator (`appTranslator` or `app.Lookup[*phrasebook.Translator]`), failing boot on a missing key;
- publish the catalog;
- return any error, so boot fails.
In controllers/api_tokens.go:
- `apiTokenScopes(raw any)` becomes `apiTokenScopes(catalog, raw)` (unexported, free to change) and checks `catalog.Allowed`. The 422 texts stay word for word.
- `APITokenStore` mints with `classes.MintAPITokenFor(app, ...)`.
- New handler `APITokenScopes(app)` answers `{"data":[{"name","label","description","built_in"}]}`, translating labels and descriptions with `appTranslator(app)` for the request locale.
In routes.go, add `g.Get("/tokens/scopes", controllers.APITokenScopes(p.app), "jwt.auth")` next to the token routes.
Lang en and pl gain the six built-in label and description keys:
- read: "Read" / "Odczyt";
- write: "Write" / "Zapis";
- ai: "AI" / "AI";
- each with a one-line description.
The README gets a "Member token scopes" section covering:
- declaring with `pact.HasFrontendTokenScopes`, with a short example declaring `inventory:read` and `inventory:write` (neutral names: a shared plugin README names no consuming application);
- the boot errors;
- mint, check and list behaviour, with `MintAPITokenFor` as the call applications should use and `MintAPIToken`'s process-wide fallback explained;
- the discovery route and its answer;
- that the admin `g15_` catalog never shows these scopes.
**New smoke `member_token_scopes_smoke_test.go`:**
- `-short` part: `CompileAPITokenScopes` with two test plugins declaring the same name fails; a plugin declaring `write` fails; a bad name fails. Call `ResetDeclaredAPITokenScopes()` in a `t.Cleanup`.
- Postgres part (created only when not `-short`):
1. A test plugin declaring `servers:write` is activated.
2. `POST /_user/api/v1/tokens` with `["servers:write"]` answers 201.
3. A test route behind `APITokenGuard` plus `middleware.Scope("servers:write")` accepts the token.
4. A `read`-only token gets 403 `Missing required scope: servers:write`.
5. `["servers:nope"]` answers 422 `The selected scope is invalid.`.
6. `GET /tokens/scopes` lists `read`, `write`, `ai` and `servers:write` with labels.
7. A second application booted in the same test without the declaring plugin refuses `["servers:write"]` with the same 422, and `MintAPITokenFor` with that application refuses it.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -run '^TestMemberTokenScopesSmoke$' -v . 2>&1 | grep -q -- '^--- PASS: TestMemberTokenScopesSmoke' && W="$(mktemp -d)" && trap 'rm -rf "$W"' EXIT && F="$(realpath ../fonoteka.go)" && U="$(realpath ../sm-demo-app/plugins/golem15/user)" && printf 'go 1.27.0
toolchain go1.27.0
use (
%s
%s/plugins/golem15/feedback
%s/plugins/golem15/fonoteka
%s/plugins/golem15/golem
%s
)
replace git.golem15.com/golem15/summercms => %s
' "$F" "$F" "$F" "$F" "$U" "$(pwd)" > "$W/go.work" && test "$(GOWORK="$W/go.work" go -C ../fonoteka.go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin)" = "$U" && GOWORK="$W/go.work" go -C ../fonoteka.go build ./... && rm -rf "$W"</automated>
<fails_when>non-zero exit, "FAIL" in the output, the PASS line missing (SKIP without Docker counts as missing), the scratch workspace not resolving sm-user-plugin to the demo's user checkout, or fonoteka.go failing to build against the modified user plugin</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "func CompileAPITokenScopes(\|func APITokenScopesFor(\|type APITokenScopeCatalog struct\|func ResetDeclaredAPITokenScopes(" ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` prints 4
- `grep -c "func MintAPITokenFor(" ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go` prints 1 and `grep -v '^\s*//' ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go | grep -c "MintAPITokenFor("` prints 1
- `git -C ../fonoteka.go status --short go.work | wc -l` prints 0 (fonoteka's workspace untouched)
- `grep -c "pact.HasFrontendTokenScopes\|pact.ValidTokenScopeName" ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` prints at least 2
- `grep -c 'g.Get("/tokens/scopes"' ../sm-demo-app/plugins/golem15/user/routes.go` prints 1
- `grep -c "AllowedAPITokenScopes = \[\]string{\"read\", \"write\", \"ai\"}" ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go` prints 1 (the built-in list keeps its exported form)
- `grep -c "The selected scope is invalid." ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go` prints at least 1
- `grep -c "HasFrontendTokenScopes\|tokens/scopes" ../sm-demo-app/plugins/golem15/user/README.md` prints at least 2
</acceptance_criteria>
<done>A plugin's declared member scopes can be minted, checked, listed and discovered; undeclared ones are refused at mint time; bad declarations fail boot; the admin token catalog is untouched; MintAPIToken's callers in applications compile unchanged.</done>
</task>
<task type="auto">
<name>Task 4: E-1 organisation logo; Journal's draft_post under the write ceiling; the demo's nginx MCP body cap; demo submodule checkouts on the local plugin commits</name>
<reversibility rating="costly">The attachment relation name `logo` is stored in system_files.field and read by applications' payloads; renaming it needs a data update.</reversibility>
<files>../sm-demo-app/plugins/golem15/user/models/organisation.go, ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml, ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go, ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml, ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml, ../sm-demo-app/plugins/golem15/user/README.md, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/README.md, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/scripts/check-deploy.sh, ../sm-demo-app/plugins/golem15/oauthserver, ../sm-demo-app/plugins/golem15/journal</files>
<read_first>
- 19.7-GAP-INPUTS.md, section E-1; 19.7-REVIEW.md, sections C-IN-01 and C-IN-03
- U models/organisation.go (`OrganisationAvatarField`, `AttachRelations`, `MorphName`), models/organisation/fields.yaml (the `avatar` fileupload), classes/avatar.go (`AvatarURL`: the attachment query, the bucket lookup, `f.Thumb`), controllers/api_controller.go `applyAvatar` (the `avatar`, `avatar_url`, `has_avatar` keys), admin_organisations_test.go (how the organisation form is exercised)
- J mcp.go (the `draft_post` prompt at ~154-166) and README.md (the MCP section); summercms.go modules/pact/mcp.go (`MCPPrompt.Writes` from plan 06)
- D deploy/nginx/demo.summercms.io.conf (`location = /mcp` at ~213-226, the server-level `client_max_body_size 2m`), scripts/check-deploy.sh (`mcp_locations` and the `/mcp` directive loop at ~224-256); summercms.go modules/pier/pier.go (`maxRequestBody = 4 << 20`)
- `git -C ../sm-demo-app submodule status plugins/golem15/oauthserver plugins/golem15/journal` and `cat ../sm-demo-app/go.work` (the demo uses those checkouts)
</read_first>
<action>
**E-1 (P-38), in U:**
- In models/organisation.go, add the constant `OrganisationLogoField = "logo"` and a second relation `{Name: OrganisationLogoField, Public: true}` in `AttachRelations`. Keep `avatar` first and unchanged.
- In fields.yaml, add a `logo` field after `avatar`: `type: fileupload`, `mode: image`, `label: golem15.user::lang.organisation.logo`, `comment: golem15.user::lang.organisation.logo_comment`, `imageWidth: 240`, `imageHeight: 120`.
- New file classes/organisation_logo.go with:
- `OrganisationLogoURL(ctx context.Context, app *backpack.App, org *models.Organisation, size int) (string, bool, error)`. Same lookup as `AvatarURL` for the organisation's morph name and the `logo` field: a thumbnail of `size` by `size` in mode `auto`; the file path when the thumbnail fails (logged); false when there is no logo or no bucket. Only a database failure is an error.
- `OrganisationLogoFields(ctx context.Context, app *backpack.App, org *models.Organisation) (map[string]any, error)`, answering `has_logo` (bool), `logo` (disk name, or nil) and `logo_url` (the 256 px URL, or nil): the avatar keys of the user payload, renamed.
- Lang en and pl gain `organisation.logo` ("Logo" / "Logo") and `organisation.logo_comment` (en "Shown wherever the site displays the organisation." / pl "Wyświetlane wszędzie, gdzie strona pokazuje organizację.").
- The README's Organisations feature and Organisations screen paragraphs name the logo and the two helpers, with the keys.
- No migration and no change to any existing payload.
**C-IN-01, in J:** set `Writes: true` on the `draft_post` prompt. The README's MCP section says the prompt is offered only to grants that may write. Commit in sm-journal-plugin.
**C-IN-03, in D:**
- Add `client_max_body_size 4m;` inside `location = /mcp`, with a comment naming pier's 4 MiB request cap.
- In scripts/check-deploy.sh, add `client_max_body_size 4m;` to the directives the `/mcp` location must carry, with the message "(pier refuses bodies over 4 MiB itself; nginx must not cut earlier)".
- Run `bash scripts/check-deploy.sh` in sm-demo-app.
- Commit those two files only.
**P-39, demo checkouts (working tree only, never committed):**
- `git -C ../sm-demo-app/plugins/golem15/oauthserver fetch "$(realpath ../sm-oauthserver-plugin)" master && git -C ../sm-demo-app/plugins/golem15/oauthserver checkout --detach FETCH_HEAD`.
- The same for `plugins/golem15/journal` from `../sm-journal-plugin`.
- Then `go -C ../sm-demo-app build -tags summercms_mcp ./...` and `go -C ../sm-demo-app test -count=1 -short ./...`.
- Record both new submodule HEADs in the SUMMARY and the deferred pointer bumps.
</action>
<verify>
<automated>GOWORK=off go -C ../sm-demo-app/plugins/golem15/user vet ./... && GOWORK=off go -C ../sm-demo-app/plugins/golem15/user test -count=1 -short ./... && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -short ./... && (cd ../sm-demo-app && bash scripts/check-deploy.sh && go build -tags summercms_mcp ./... && go test -count=1 -short ./...) && test "$(git -C ../sm-demo-app/plugins/golem15/oauthserver rev-parse HEAD)" = "$(git -C ../sm-oauthserver-plugin rev-parse HEAD)" && test "$(git -C ../sm-demo-app/plugins/golem15/journal rev-parse HEAD)" = "$(git -C ../sm-journal-plugin rev-parse HEAD)"</automated>
<fails_when>non-zero exit, "FAIL" in the output, check-deploy.sh printing an error, or a demo submodule checkout not on its plugin repository's HEAD</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'OrganisationLogoField = "logo"' ../sm-demo-app/plugins/golem15/user/models/organisation.go` prints 1 and `grep -c "OrganisationAvatarField" ../sm-demo-app/plugins/golem15/user/models/organisation.go` still prints at least 2
- `grep -A3 "^ logo:" ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml | grep -c "fileupload"` prints 1
- `grep -c "func OrganisationLogoURL(\|func OrganisationLogoFields(" ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go` prints 2 and `grep -c '"has_logo"\|"logo_url"' ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go` prints at least 2
- `git -C ../sm-demo-app/plugins/golem15/user log --oneline 72de822..HEAD -- updates/ | wc -l` prints 0 (no migration added since the plugin HEAD before the gap closure)
- `grep -A12 "Name: \"draft_post\"" ../sm-journal-plugin/mcp.go | grep -c "Writes: *true"` prints 1
- `grep -A16 "location = /mcp {" ../sm-demo-app/deploy/nginx/demo.summercms.io.conf | grep -c "client_max_body_size 4m;"` prints 1 and `grep -c "client_max_body_size 4m;" ../sm-demo-app/scripts/check-deploy.sh` prints 1
- `git -C ../sm-demo-app diff --cached --name-only | wc -l` prints 0 after the task's commits (the submodule moves stay unstaged)
</acceptance_criteria>
<done>Organisations carry an editable logo that applications can expose under the avatar-style keys; Journal's drafting prompt follows the write ceiling; the demo's nginx no longer cuts MCP bodies below pier's cap; the demo runs this plan's plugin code for verification without committing unreleased pointers.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**sm-user-plugin (local commits, untagged):**
- The account link:
- auth_time-based confirm;
- `POST /_user/api/v1/backend-link/show` (GET kept);
- the `#code=` link with `golem15.user.backend_links.page_path`;
- `BackendLinksFor`.
- Access revocation:
- `AccessRevokedEvent` and `FireAccessRevoked` (ban, password reset, admin-set password, self password change);
- banned users excluded from `LinkedBackendUser`;
- `FireSubjectEvent` and `FireSubjectEventAfterCommit` (token-specific revoke on `DELETE /tokens/{id}`);
- `last_used_ip` on the MCP personal-token path.
- Member token scopes (E-2): `APITokenScope`, `APITokenScopeCatalog`, `CompileAPITokenScopes`, `APITokenScopesFor`, `MintAPITokenFor`, the test seam `ResetDeclaredAPITokenScopes`, `GET /_user/api/v1/tokens/scopes`, and boot validation.
- Organisation logo (E-1): the attachOne `logo` and `OrganisationLogoField`, the form field, `OrganisationLogoURL` and `OrganisationLogoFields`.
- README and lang updates.
**sm-oauthserver-plugin (local commits, untagged):**
- The verifier and MCP refresh refuse banned and pre-cutoff tokens.
- `Cascade.OnAccessRevoked`, `OnBackendLinked`, `OnBackendUnlinked` and `OnBackendLinkChanged`, all built on the one revocation function `revokeUserGrants` (`OnAccessRevoked` passes the MCP and OIDC kinds; the link handlers pass MCP with the `backend` scope only).
- `issuedBeforeCutoff`, the whole-second-aware `tokens_valid_after` rule.
- The client-specific revoke event.
- Subject-event helpers delegate to the user plugin.
- README.
**sm-journal-plugin (local commit, untagged):** `draft_post` with `Writes: true`; README.
**vue-demo-app (local commit):** `/link-admin` reads the fragment, uses POST show, shows the signed-in account, and keeps the code in sessionStorage; `app/lib/linkCode.ts`; i18n keys.
**sm-demo-app (local commits):**
- the vue-demo-app pointer bump;
- nginx `client_max_body_size 4m` on `/mcp`, enforced by `check-deploy.sh`;
- the oauthserver and journal submodule checkouts moved to the local plugin commits (working tree only).
**Smokes:** `TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke` (user plugin); `TestMCPRevocationSmoke` (oauthserver).
**Decisions:** P-34 to P-39.
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| site browser to the link pages | a signed-in browser (possibly shared) confirms a link code |
| web server logs and analytics to the link code | anything that records request lines or query strings |
| user plugin to oauthserver (events) | user state changes must reach the OAuth grants that act for the user |
| MCP verifier and refresh to the user record | a grant is only as good as the account behind it |
| plugins to the member token catalog | plugin-declared scope names are stored on member tokens and checked by route middleware |
| admins to organisation records | backend editors upload organisation images that the frontend shows |
| nginx to pier | request size limits in front of the MCP endpoint |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-57 | Elevation of privilege | banned or password-reset users keeping MCP grants and the linked admin's reach | high | mitigate | verifier and refresh refuse banned users and tokens created before `tokens_valid_after + 1s` (whole-second cutoff, so the same second is covered); the revoke uses the untruncated instant; `AccessRevokedEvent` revokes MCP tokens and ends sessions; `LinkedBackendUser` excludes banned users; TestMCPRevocationSmoke here, TestMCPVerifierRevocation, TestMCPGrantRevocation and TestAccessRevocation in plan 09 |
| T-19.7-58 | Spoofing | the one-shot link code exposed in access logs, request logs and browser history | high | mitigate | `#code=` fragment; POST show; the code kept in sessionStorage across sign-in; the confirming account shown before the button; GET show kept only for compatibility and documented as deprecated |
| T-19.7-59 | Elevation of privilege | a backend grant re-targeted to an administrator the user never consented to | high | mitigate | backend-scoped MCP tokens revoked on every link and unlink (P-36), sessions ended by client |
| T-19.7-60 | Elevation of privilege | a member token minted with an undeclared scope, or a declared scope shadowing a built-in or another plugin's | medium | mitigate | catalog validation at boot (pattern, built-ins reserved, duplicates); the HTTP mint and `MintAPITokenFor` check the application's own catalog, so another application's declaration is refused; 422 unchanged; the legacy `MintAPIToken` fallback is process-wide and documented |
| T-19.7-61 | Tampering | organisation logo uploads | low | mitigate | image-mode fileupload through lagoon/attach (the avatar's validation), behind the Organisations screen permission `golem15.users.access_users` |
| T-19.7-62 | Denial of service | nginx's 2 MiB cap answering HTML 413 before pier's 4 MiB JSON-RPC error | low | mitigate | `client_max_body_size 4m` on `location = /mcp`, enforced by check-deploy.sh |
| T-19.7-63 | Repudiation | personal tokens used at /mcp showing a stale last_used_ip | low | mitigate | `MCPResolvePersonalToken` stamps the client address from `towel.ClientIP` |
| T-19.7-SC | Tampering | package installs | low | mitigate | no Go module or npm package added (go.mod and go.sum unchanged in the three plugins; vue-demo-app's pnpm-lock.yaml unchanged; only the existing lock is used for its test run) |
</threat_model>
<verification>
- User plugin: `GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...` on a Docker host
- oauthserver: `go vet ./... && go test -count=1 ./...` on a Docker host, with the retargeted local go.work
- Journal: `go vet ./... && go test -count=1 -short ./...`
- Demo: `bash scripts/check-deploy.sh`, `go build -tags summercms_mcp ./...` and `go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' .` on a Docker host
- `bash scripts/check-phase19.7.sh --plugins` (the framework repository) prints `additive:` lines for all three plugins and PASS
- fonoteka.go and sm-bm-app still build against their own pinned user checkouts: `(cd ../fonoteka.go && go build ./...) && (cd ../sm-bm-app && go build ./...)`. fonoteka.go also builds against this plan's user plugin through the scratch workspace of Task 3's verify.
</verification>
<success_criteria>
- C-CR-01 (user and oauthserver parts), C-CR-02 (plugin part), C-WR-01 and C-WR-02 are fixed; C-IN-01..C-IN-06 are fixed
- E-1 and E-2 are delivered as the GAP-INPUTS ask states, including every E-2 acceptance item (proven by the smoke now and the named tests of plan 09)
- Every change in the three plugins passes the gate's additive check; plugin go.mod require lines are unchanged
- Nothing is tagged, pushed or deployed; the demo's plugin pointer bumps wait for the release
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-08-SUMMARY.md` when done. Commit it in the framework repository as a planning doc. It contains:
- a commit table per repository, mapping each sha to its finding or escalation ids;
- the previous and new go.work target in sm-oauthserver-plugin;
- the detached submodule HEADs in sm-demo-app;
- a "Delivered differently" note for E-1. The plugin serves no frontend organisation payload, so the escalation's "frontend organisation payloads gain the logo" is delivered as `OrganisationLogoURL` and `OrganisationLogoFields`. They give applications the `has_logo`, `logo` and `logo_url` keys for their own organisation payloads. Name this for the sm-servers-app hand-off;
- the OIDC decision (ban and reset revoke OIDC grants too) and the rows it touches.
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. The releases happen only after phase verification, in this order:
1. framework v0.5.0;
2. the plugin tags that require it (sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin);
3. the application bumps: the demo's plugin pointers, and pushing vue-demo-app before sm-demo-app.
</output>

View File

@@ -0,0 +1,343 @@
---
phase: 19.7-generic-mcp-module
plan: 08
subsystem: plugins
status: complete
gap_closure: true
tags: [sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, auth_time, account-link, revocation, oidc, member-token-scopes, organisation-logo, nginx, gap-closure]
requires:
- phase: 19.7-06
provides: pact.MCPPrompt.Writes (Journal's draft_post)
- phase: 19.7-07
provides: bouncer.VerifyAuthTime, pact.HasFrontendTokenScopes / FrontendTokenScope / ValidTokenScopeName, MCPSubjectChanged.TokenID / ClientID, towel.ClientIP
provides:
- "sm-user-plugin: auth_time-based account link confirm, POST /backend-link/show, #code= link with backend_links.page_path, BackendLinksFor, BackendLinkPagePath"
- "sm-user-plugin: AccessRevokedEvent + FireAccessRevoked (ban, password reset, admin-set password, self password change), banned users excluded from LinkedBackendUser, FireSubjectEvent / FireSubjectEventAfterCommit, last_used_ip on the MCP personal-token path"
- "sm-user-plugin: member token scopes (APITokenScope, APITokenScopeCatalog, CompileAPITokenScopes, APITokenScopesFor, MintAPITokenFor, ResetDeclaredAPITokenScopes, GET /_user/api/v1/tokens/scopes)"
- "sm-user-plugin: organisation logo (OrganisationLogoField, OrganisationLogoURL, OrganisationLogoFields, OrganisationLogoSize)"
- "sm-oauthserver-plugin: verifier and MCP refresh refuse banned and pre-cutoff tokens (issuedBeforeCutoff); Cascade.OnAccessRevoked / OnBackendLinked / OnBackendUnlinked / OnBackendLinkChanged over one revokeUserGrants; client-specific revoke event"
- "sm-journal-plugin: draft_post with Writes: true"
- "sm-demo-app: nginx client_max_body_size 4m on /mcp enforced by check-deploy.sh; vue-demo-app /link-admin fragment flow; TestMCPEndToEnd updated for C-WR-02"
affects: [19.7-09 (named unit tests, gate, security-review corrections), framework v0.5.0 and the plugin tags, sm-servers-app (E-1, E-2 hand-off)]
actuals:
tokens: 45949
tasks: 4
commits: 0
commits_by_repo:
sm-user-plugin: 4
sm-oauthserver-plugin: 1
sm-journal-plugin: 1
vue-demo-app: 1
sm-demo-app: 3
plan_head_before: 5aeea304d02080bcffb7b127153e0e92ba91d2ab
plan_head_after: 5aeea304d02080bcffb7b127153e0e92ba91d2ab
tech-stack:
added: []
patterns:
- "A sign-in proof reads auth_time (bouncer.VerifyAuthTime), never iat; a token without the claim is treated as stale"
- "One revocation event per user change (AccessRevokedEvent) fired in the write's transaction; listeners revoke elsewhere-held grants; the after-commit subject event ends sessions"
- "Whole-second cutoffs compared as created_at < cutoff + 1s (issuedBeforeCutoff)"
- "Per-application catalogs with a process-wide fallback only for a legacy signature (MintAPIToken vs MintAPITokenFor)"
- "A one-shot secret travels in a URL fragment and a POST body, never a logged URL"
key-files:
created:
- ../sm-demo-app/plugins/golem15/user/classes/access_revoked.go
- ../sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go
- ../sm-demo-app/plugins/golem15/user/classes/organisation_logo.go
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_smoke_test.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_smoke_test.go
- ../sm-oauthserver-plugin/mcp_revocation_smoke_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.ts
modified:
- ../sm-demo-app/plugins/golem15/user/controllers/backend_link_controller.go
- ../sm-demo-app/plugins/golem15/user/classes/backend_links.go
- ../sm-demo-app/plugins/golem15/user/account_contributions.go
- ../sm-demo-app/plugins/golem15/user/routes.go
- ../sm-demo-app/plugins/golem15/user/config/config.yaml
- ../sm-demo-app/plugins/golem15/user/classes/events.go
- ../sm-demo-app/plugins/golem15/user/classes/subject_events.go
- ../sm-demo-app/plugins/golem15/user/classes/mcp_guard.go
- ../sm-demo-app/plugins/golem15/user/classes/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/users_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/controllers/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/console/api_tokens.go
- ../sm-demo-app/plugins/golem15/user/plugin.go
- ../sm-demo-app/plugins/golem15/user/models/organisation.go
- ../sm-demo-app/plugins/golem15/user/models/organisation/fields.yaml
- ../sm-demo-app/plugins/golem15/user/lang/en/lang.yaml
- ../sm-demo-app/plugins/golem15/user/lang/pl/lang.yaml
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/classes/mcp_verifier.go
- ../sm-oauthserver-plugin/classes/mcp_store.go
- ../sm-oauthserver-plugin/classes/cascade.go
- ../sm-oauthserver-plugin/classes/subject_events.go
- ../sm-oauthserver-plugin/controllers/authorizations.go
- ../sm-oauthserver-plugin/plugin.go
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/mcp.go
- ../sm-journal-plugin/README.md
- ../sm-demo-app/vue-demo-app/app/pages/link-admin.vue
- ../sm-demo-app/vue-demo-app/i18n/locales/en.json
- ../sm-demo-app/vue-demo-app/i18n/locales/pl.json
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/scripts/check-deploy.sh
- ../sm-demo-app/mcp_e2e_test.go
key-decisions:
- "P-34: the account link confirm reads auth_time (VerifyAuthTime); a token without it answers 409 stale_session; the code travels as #code= and through POST /backend-link/show; GET show kept, deprecated"
- "P-35: AccessRevokedEvent + FireAccessRevoked from ban (newly banned only), password reset, admin-set password and self-service change; Before is the untruncated instant (self change: iat second); verifier and MCP refresh refuse banned users and created_at < tokens_valid_after + 1s"
- "P-35 / user decision 2026-10-23: OnAccessRevoked ends MCP and OIDC grants alike (tokens revoked, consents and unused codes deleted, subjects kept)"
- "P-36: backend-scoped MCP grants are revoked on every link and unlink, with a client-specific revoked event per affected client"
- "P-37: member token scopes are validated per application (MintAPITokenFor, POST /tokens); MintAPIToken keeps a process-wide fallback"
- "P-38: organisation logo is a second public attachOne; the plugin ships OrganisationLogoURL / OrganisationLogoFields instead of a payload it does not serve"
- "MCP refresh refusals sit in ByTokenHashForUpdate only (the rotation lookup), not in ByAPITokenIDForUpdate, so wristband's Revoke still finds and kills a banned user's lineage"
requirements-completed: [SC-4, D-03, D-05, D-07, D-08, D-09, D-19, D-20, D-21, D-26, D-27, D-28, E-1, E-2]
duration: 41min
completed: 2026-10-23
---
# Phase 19.7 Plan 08: Plugin gap closure (lane C, E-1, E-2) Summary
Lane C of the code review and both sm-servers-app escalations are closed in the plugins and the demo, additively. The account link is proven by the session's `auth_time`, so a refreshed old session no longer passes, and the link code travels only in a URL fragment and a POST body. A ban, a password reset or any password change now ends the user's MCP and OIDC grants and their MCP sessions. The MCP verifier and refresh grant refuse banned users and tokens from before the cutoff. A `backend` grant cannot outlive or be re-targeted by a link change. Plugins can declare member token scopes, and organisations have a logo.
## Performance
- **Duration:** about 41 min
- **Started:** 2026-10-23T14:30Z (ledger recorded 14:38:55Z)
- **Completed:** 2026-10-23T15:11Z
- **Tasks:** 4 of 4
- **Files modified:** 47 across five repositories (7 created)
## Commits and findings
The framework repository has no code commit in this plan; only this SUMMARY is committed there. The `commits: 0` in the frontmatter is the framework ledger count. `commits_by_repo` holds the counts for the other repositories.
**sm-user-plugin** (`../sm-demo-app/plugins/golem15/user`, master, base 72de822)
| Commit | Task | Findings |
|--------|------|----------|
| 32f9259 `fix: account link proof by auth_time, code in the URL fragment and POST show` | 1 (tracer) | C-CR-02 (plugin side), C-WR-01, C-IN-06 |
| 970719c `fix: access-revoked event on ban and password changes, banned users lose their link, token-specific MCP revoke` | 2 | C-CR-01 (user side), C-WR-02 (events confirmed), C-IN-02 (token), C-IN-04, C-IN-05 |
| ee5c096 `feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)` | 3 | E-2 |
| f20a8d3 `feat: organisation logo (attachOne logo) and payload helpers` | 4 | E-1 |
**sm-oauthserver-plugin** (master, base 2929c91)
| Commit | Task | Findings |
|--------|------|----------|
| 671513d `fix: revoke OAuth grants on ban and password changes, refuse banned and pre-cutoff MCP tokens, backend grants follow the link` | 2 | C-CR-01, C-WR-02, C-IN-02 (client), C-IN-05 |
**sm-journal-plugin** (main, base a9a9b80)
| Commit | Task | Findings |
|--------|------|----------|
| b129902 `fix: draft_post prompt is offered only to grants that may write` | 4 | C-IN-01 |
**vue-demo-app** (master, base 21a22b6)
| Commit | Task | Findings |
|--------|------|----------|
| 0125e29 `fix: link page reads the code from the fragment, shows the request by POST and the signed-in account` | 1 | C-WR-01, C-CR-02 (signed-in account shown) |
**sm-demo-app** (master, base 6fa2d56)
| Commit | Task | Findings |
|--------|------|----------|
| 00f57bf `chore: bump vue-demo-app (link page: code in the fragment, POST show)` | 1 | vue-demo-app pointer only |
| 3b569a1 `fix: nginx accepts MCP bodies up to pier's 4 MiB cap` | 4 | C-IN-03 |
| 7731641 `test: TestMCPEndToEnd expects a link change to end the backend grant` | 4 | C-WR-02 (e2e, deviation 3) |
Nothing was tagged, pushed or deployed.
## Accomplishments
- **Account link proof (Task 1, tracer).**
- `BackendLinkConfirm` reads `bouncer.VerifyAuthTime(SessionToken, secret, AudienceUser)`. A subject mismatch or a verify error answers 401. A token without `auth_time` answers the existing 409 `stale_session`, and so does an `auth_time` before the request. `ConfirmLinkRequest` keeps its signature; the parameter is now `sessionAuthTime`.
- `BackendLinkShowPost` serves `POST /_user/api/v1/backend-link/show` with `{"code"}` and answers exactly like the GET route. Both show routes send `Cache-Control: no-store, private` (`writeNoStoreJSON`), and the README marks the GET route deprecated.
- My account answers `FrontendURL + BackendLinkPagePath + "#code=" + code`. The new key `golem15.user.backend_links.page_path` defaults to `/link-admin` and gets a leading `/` when it lacks one.
- vue-demo-app `/link-admin` reads `#code=` with `readLinkCode`, stores the code under `LINK_CODE_KEY` in sessionStorage and removes the fragment with `history.replaceState`. It shows the request through POST, shows "Signed in as …" with a "Not you? Sign out" action, and signs in through `/login?next=/link-admin`, so the code never appears in a URL. The stored code is removed on success and when the request is gone. The `signInAgain` text is reworded in en and pl.
- The tracer gate re-ran the verify end to end (3 PASS, vue tests green) before expansion.
- **Revocation chain (Task 2).**
- `AccessRevokedEvent` (`user.AccessRevoked`) and `FireAccessRevoked` fire the event in the transaction, then one `revoked` subject event per user after the commit. The reasons are `ban`, `password_reset`, `password_set_by_admin` and `password_changed`, exported as `AccessRevoked*` constants.
- Callers:
- the `ban` bulk action, through the `banUsers` wrapper (ThrottleStates before and after, newly banned ids only);
- `ResetPassword`, now inside `lagoon.Transaction`, with the untruncated `now`;
- `ChangePassword`, also inside a transaction, with `cutoff + 1s`;
- the admin password store in `FormBeforeUpdate`, using the save's tx and the untruncated instant.
- `LinkedBackendUser` excludes banned users (`user_throttle.is_banned`).
- `FireSubjectEvent` and `FireSubjectEventAfterCommit` are new; the old helpers wrap them. `DELETE /tokens/{id}` fires with `TokenID`.
- `MCPResolvePersonalToken` stamps `last_used_ip` from `towel.ClientIP`.
- `requestUsable` uses `IsUsableAt`. `BackendLinksFor` replaces the two duplicate lookups.
- oauthserver:
- `issuedBeforeCutoff` is used by the verifier and by the MCP refresh lookup. Both also refuse banned users.
- `revokeUserGrants` is the one revocation function.
- `OnAccessRevoked` covers the MCP and OIDC kinds. `OnBackendLinked`, `OnBackendUnlinked` and `OnBackendLinkChanged` revoke only `backend`-scoped MCP tokens and fire a revoked event per client.
- The authorized-apps revoke fires with `ClientID`.
- The subject-event helpers delegate to the user plugin.
- **Member token scopes (Task 3, E-2).**
- The scope types are `APITokenScope` and `APITokenScopeCatalog` (`Scopes`, `Allowed`).
- `CompileAPITokenScopes` refuses a bad name, a built-in, a duplicate (the error names both plugins) and an empty label or description.
- Boot (`bootAPITokenScopes`) also checks `::` phrase keys against the translator, publishes the catalog and fails on any error.
- `APITokenScopesFor` returns the published catalog, or the built-ins alone when none is published.
- `MintAPITokenFor` and `MintAPIToken` share `mintAPIToken`. The legacy `MintAPIToken` falls back to the process-wide declared set, which `ResetDeclaredAPITokenScopes` clears for tests.
- `POST /tokens` and `user:token:create` use `MintAPITokenFor`, and the 422 text is unchanged.
- `GET /_user/api/v1/tokens/scopes` (behind `jwt.auth`) lists the scopes with translated labels.
- The built-in label and description keys are added in en and pl, and the README has a "Member token scopes" section.
- `AllowedAPITokenScopes` keeps its exported form.
- **Logo, Journal, nginx, demo (Task 4).**
- `OrganisationLogoField = "logo"` is the second public attachOne, after the avatar.
- The Organisations form gains a `logo` image fileupload (240×120).
- `OrganisationLogoURL`, `OrganisationLogoFields` and `OrganisationLogoSize` (256) are new. No migration was added (`72de822..HEAD -- updates/` is empty).
- Journal's `draft_post` now has `Writes: true`.
- nginx `location = /mcp` gets `client_max_body_size 4m;`, and check-deploy.sh enforces it. A deliberately removed directive made the check fail as intended; the file was then restored.
## Delivered differently: E-1 (for the sm-servers-app hand-off)
The escalation asked that "the frontend organisation payloads gain the logo". sm-user-plugin serves no frontend organisation payload: no route answers an organisation object. The logo is therefore delivered as two exported helpers that applications call when they build their own organisation payloads:
- `classes.OrganisationLogoFields(ctx, app, org)` answers `has_logo` (bool), `logo` (the attachment's disk name, or nil) and `logo_url` (a 256 px thumbnail URL, or nil). These are the user payload's avatar keys with "avatar" replaced by "logo". An application merges the map into its organisation JSON.
- `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size, and false when there is no logo or no file bucket.
The logo is uploaded on the backend Organisations form (field `logo`) and stored in `system_files` with `field = 'logo'`.
## OIDC decision (user, 2026-10-23) and the rows it touches
A ban, a password reset, an admin-set password or the user's own password change ends every OAuth grant of the user, MCP agents and OIDC sign-ins alike. `Cascade.OnAccessRevoked` calls `revokeUserGrants(ctx, ev.Tx, ev.UserIDs, ev.Before, [mcp, oidc], "")`, which touches these rows of the users at clients of kind `mcp` or `oidc`:
| Table | Rows | Change |
|-------|------|--------|
| `golem15_oauthserver_access_tokens` | unrevoked, `created_at < Before` | `revoked_at`, `updated_at` set |
| `golem15_oauthserver_refresh_tokens` | unrevoked, `created_at < Before` | `revoked_at`, `updated_at` set |
| `golem15_oauthserver_consents` | `created_at` and `updated_at` before `Before` (or NULL) | deleted, so an OIDC client asks for consent again |
| `golem15_oauthserver_auth_codes` | `consumed_at IS NULL`, `created_at < Before` | deleted (a pending OIDC code cannot mint a token afterwards) |
| `golem15_oauthserver_mcp_codes` | `used_at IS NULL`, `created_at < Before` | deleted |
| `golem15_oauthserver_subjects` | — | kept (a restored or unbanned user keeps the same `sub`) |
Link changes (C-WR-02) call the same function with kind `mcp` and scope `backend`, and only revoke tokens. `TestMCPRevocationSmoke` checks the OIDC rows after a ban: no unrevoked access token, no consent, no unused code, and the subject kept.
## Local environment (not committed)
- **sm-oauthserver-plugin go.work.** The `sm-user-plugin` replace previously pointed at `../fonoteka.go/plugins/golem15/user` and now points at `../sm-demo-app/plugins/golem15/user`. See deviation 1: go.work is tracked in this repository, so it shows as modified and was deliberately left uncommitted.
- **sm-demo-app submodule checkouts (detached, working tree only):**
- `plugins/golem15/oauthserver` at 671513d1fccb2defce8fbce517bde17619253dbb (committed pointer 8bbec72)
- `plugins/golem15/journal` at b1299021cd4031bf5430adcdbb1b139a8d1c3eb0 (committed pointer 4ab94da)
- `plugins/golem15/user` at f20a8d3b98d3bafa5a58bcacc54ebbd427affa45 (committed pointer at v0.1.10). This is the plugin checkout itself, and the pointer was already shown as modified before this plan.
- **Deferred pointer bumps:** commit the oauthserver, journal and user pointers in sm-demo-app after the plugin tags (release order: framework v0.5.0, then the plugin tags, then the application bumps). vue-demo-app 0125e29 must be pushed before sm-demo-app 00f57bf.
## Verification
- User plugin:
- `GOWORK=off go vet ./...` clean.
- `GOWORK=off go test -count=1 ./...` green with Docker (root 58 s, classes, models, updates), run after the last change.
- `TestBackendLinkAuthTimeSmoke`, `TestBackendLink`, `TestBackendLinkSmoke`, `TestMemberTokenScopesSmoke` (both subtests), `TestUserMCPGuard` and `TestUserMCPGuardSmoke` PASS.
- oauthserver:
- `go vet ./...` clean.
- `go test -count=1 ./...` green with Docker.
- `TestMCPRevocationSmoke`, `TestMCPTokenVerifier`, `TestMCPConsent`, `TestMCPConsentSmoke`, `TestMCPTokenVerifierSmoke`, `TestMCPStore` and `TestMCPStoreSmoke` PASS.
- Journal: `go vet ./...` and `go test -count=1 -short ./...` green (`TestJournalMCP` and `TestJournalMCPSmoke` included).
- Demo:
- `bash scripts/check-deploy.sh` prints ok.
- `go build -tags summercms_mcp ./...` and `go test -count=1 -short ./...` are green.
- `go test -count=1 -tags summercms_mcp -run '^TestMCPEndToEnd$' .` PASS (10 subtests) on the detached plugin checkouts.
- vue-demo-app: `pnpm run test` (14 files, 178 tests) and `pnpm run build` are green.
- `bash scripts/check-phase19.7.sh --plugins` PASS. It prints `additive:` lines:
- user: nothing removed since 930b158 or v0.1.9;
- oauthserver: nothing removed since d31f8e7;
- journal: nothing removed since ffa2c8f.
- fonoteka.go:
- builds against this plan's user plugin through the scratch GOWORK of Task 3's verify;
- `go build ./...` also passes against its own checkout;
- its go.work is untouched (status count 0).
- sm-bm-app: `go build ./...` passes.
- go.mod and go.sum are unchanged in all three plugins (no `go get`), and pnpm-lock.yaml is unchanged.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] sm-oauthserver-plugin's go.work is tracked, not git-ignored**
- **Found during:** Task 2, step 0
- **Issue:** P-39 assumed a git-ignored go.work. It is tracked (`git ls-files go.work`, committed in 459e389), so retargeting it leaves ` M go.work`, and the acceptance line "status --short go.work prints 0" cannot hold while the plan's own verify (`go -C ../sm-oauthserver-plugin vet ./...`) needs the retarget.
- **Fix:** I retargeted go.work in the working tree and did not commit it, as the plan says ("never commit go.work"). The previous target is recorded above. Before the release, either restore the line or decide whether go.work should leave version control.
- **Files:** ../sm-oauthserver-plugin/go.work (uncommitted)
**2. [Rule 1 - Bug] Show routes could not set no-store through writeJSON**
- **Found during:** Task 1
- **Issue:** `writeJSON` always overwrites `Cache-Control` with `no-cache, private`.
- **Fix:** the new `writeNoStoreJSON` serves both show routes, including their 404s.
- **Commit:** 32f9259
**3. [Rule 1 - Test pinned old behaviour] Demo TestMCPEndToEnd expected a backend grant to survive an unlink**
- **Found during:** Task 4 (end-to-end run on the detached checkouts)
- **Issue:** two subtests ("journal tools as the linked admin" ending, and "unlink sends list_changed") expected the backend session to keep running with a shorter tool list after an unlink. C-WR-02 (P-36) revokes that grant by design.
- **Fix:**
- The first subtest now checks three things after the unlink: the backend session ends, the backend token answers 401, and a grant without `backend` still lists `user_me` and no Journal tool.
- The second subtest is now "a link change ends the backend grant": the old token stays 401 after a relink to another admin, and a new grant reaches the new admin's tools.
- This test file was not in the plan's file list. It passes only with the plugin commits (the detached checkouts) until the pointer bumps.
- **Commit:** 7731641 (sm-demo-app)
**4. [Rule 1 - Correctness] MCP refresh refusals only in the rotation lookup**
- **Found during:** Task 2
- **Issue:** the plan put the refusals in `mcpTx.refresh`, which wristband's `Revoke` (connected-app revocation) also uses through `ByAPITokenIDForUpdate`. A "not found" there would skip killing a banned user's lineage.
- **Fix:** `refresh` is split into `refreshRow` and `refreshRecord`. Only `ByTokenHashForUpdate` (the refresh grant) applies `refreshUserUsable`.
- **Commit:** 671513d
**5. [Rule 2 - Missing critical] Full revocation also deletes unused authorization codes**
- **Found during:** Task 2
- **Issue:** an OIDC or MCP code issued before a ban or reset could still be exchanged for a new token after it.
- **Fix:** `revokeUserGrants` (full mode) deletes unconsumed `auth_codes` and unused `mcp_codes` created before `Before`. Recorded in the OIDC table above.
- **Commit:** 671513d
**6. [Plan detail] revokeUserGrants signature**
- It takes a `scope` argument (empty for all scopes) and returns the touched `(user, client)` pairs (`[]grantRef`), so the link handlers can share the one function, as the plan's artifact list describes. It is unexported.
**7. [Rule 1] Built-in scope phrase keys spelled out**
- `TestAdminRegistration` collects phrase-key string literals from the sources. A concatenated prefix (`…api_tokens.scope_`) failed it, so the six keys are literal (`builtInAPITokenScopeKeys`).
- **Commit:** ee5c096
**8. [Scope] Additional exports**
- `BackendLinkPagePath`, `DefaultBackendLinkPagePath`, the `AccessRevoked*` reason constants and `OrganisationLogoSize` were added (additive) and are documented in the README.
- Also documented in the README:
- `BackendLinkShowPost` was missing from the controllers line in 32f9259 and was added in 970719c;
- `TestBackendLinkAuthTimeSmoke` in the Testing section.
### Tests changed for old behaviour
- `backend_link_test.go` `TestBackendLink` "fresh sign-in at the second" now crafts `auth_time` tokens and adds a no-`auth_time` case that expects `stale_session` (it pinned the iat confirm). Commit 32f9259.
- `backend_link_smoke_test.go`: the link URL regexp is `…/link-admin#code=…`. Commit 32f9259.
- `password_routes_test.go` `TestPasswordPublicRoutes`: route count 21 → 22 (POST show, 32f9259), then 23 (GET /tokens/scopes, ee5c096).
- `mcp_guard_smoke_test.go` `TestUserMCPGuardSmoke`: the token revoke now expects `TokenID` (new `subjectSpy.hasEvent`). Commit 970719c.
- `models/admin_models_test.go` `TestAdminModelContracts` expects 2 organisation attach relations, avatar then logo. `admin_organisations_test.go` `TestAdminOrganisations` expects the `logo` field. Commit f20a8d3.
- oauthserver `mcp_consent_smoke_test.go` `TestMCPConsentSmoke`: the authorized-apps revoke event carries `ClientID`. Commit 671513d.
- sm-demo-app `mcp_e2e_test.go` `TestMCPEndToEnd` (deviation 3). Commit 7731641.
## Notes for plan 09
- Named tests: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`, `TestAccessRevocation` (threat register T-19.7-57). Unit tests for `linkCode.ts` (`readLinkCode`, `isLinkCode`). `OrganisationLogoFields` with and without a logo.
- `FireAccessRevoked` is not called by `UnbanUsers` or by the `user:token:revoke` CLI; neither needs it. The `admin:token:revoke` CLI still fires no MCP event (from plan 07).
- `connectWatching` in the demo's mcp_e2e_test.go is now unused.
- Pre-existing and out of scope: `gofmt -l` lists sm-journal-plugin `classes/blog_editor_test.go`.
## Known Stubs
None.
## Threat Flags
None beyond the plan's register. The new surfaces are covered there:
- `POST /backend-link/show` (T-19.7-58);
- `GET /tokens/scopes`, which needs `jwt.auth` and lists only scope metadata (T-19.7-60);
- the organisation logo upload (T-19.7-61).
## Self-Check: PASSED
- FOUND: classes/access_revoked.go, classes/api_token_scopes.go, classes/organisation_logo.go, backend_link_auth_time_smoke_test.go, member_token_scopes_smoke_test.go (user); mcp_revocation_smoke_test.go (oauthserver); app/lib/linkCode.ts (vue-demo-app)
- FOUND: 32f9259, 970719c, ee5c096, f20a8d3 (user); 671513d (oauthserver); b129902 (journal); 0125e29 (vue-demo-app); 00f57bf, 3b569a1, 7731641 (sm-demo-app)
- No commit deleted a tracked file.

View File

@@ -0,0 +1,566 @@
---
phase: 19.7-generic-mcp-module
plan: 09
type: execute
wave: 4
depends_on: ["19.7-06", "19.7-07", "19.7-08"]
gap_closure: true
files_modified:
- scripts/check-phase19.7.sh
- modules/pier/reaper_test.go
- modules/pier/reserve_test.go
- modules/pier/lock_scope_test.go
- modules/pier/widening_test.go
- modules/pier/stateless_fanout_test.go
- modules/pier/personal_binding_test.go
- modules/pier/dispatch_hardening_test.go
- modules/pier/catalog_path_fields_test.go
- modules/pier/prompt_ceiling_test.go
- modules/pier/token_events_test.go
- modules/pier/export_test.go
- modules/bouncer/auth_time_test.go
- modules/pact/frontend_token_scopes_test.go
- modules/towel/client_ip_test.go
- modules/cabana/account_contributions_hardening_test.go
- modules/cabana/record_action_prompt_localized_test.go
- modules/cabana/token_scope_name_test.go
- modules/cabana/account_token_events_test.go
- modules/cabana/token_resolver_remote_test.go
- admin/tests/app/hotkeysEditing.test.ts
- admin/tests/account/AccountContributions.test.ts
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go
- ../sm-demo-app/plugins/golem15/user/access_revocation_test.go
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/api_token_events_test.go
- ../sm-oauthserver-plugin/mcp_revocation_test.go
- ../sm-journal-plugin/mcp_prompt_writes_test.go
- ../sm-demo-app/mcp_revocation_e2e_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts
- ../sm-demo-app/vue-demo-app
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
autonomous: true
requirements: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-19, D-20, D-21, D-25, D-26, D-27, D-31, E-1, E-2]
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 4
confidence: low
must_haves:
truths:
- "Every fix of plans 06-08 (A-CR-01, A-WR-01..09, A-IN-01..08, B-WR-01..06, B-IN-01..04, C-CR-01, C-CR-02, C-WR-01, C-WR-02, C-IN-01..06) and both escalations have a named test that the phase gate requires by name, and the gate refuses a skipped, zero-match or missing run (D-31: unit tests last)"
- "E-2's acceptance list is a test, item by item and verbatim: a plugin declaring servers:write mints a usr_ token with that scope through POST /tokens and a middleware.Scope(\"servers:write\") route accepts it; a token without the scope gets 403 \"Missing required scope: servers:write\"; an undeclared scope answers 422 at mint time; two plugins declaring one name, or a plugin redeclaring read, write or ai, fails boot; the discovery answer lists the built-ins and the declared scopes with labels"
- "The gate cannot pass on a local accident: every repository must resolve the framework to this checkout and the OAuth server plugin must resolve the user plugin to USER_PLUGIN, a demo plugin submodule may be ahead of its pin only by commits its canonical checkout holds (unreleased, reported), and PHASE197_RELEASED=1 refuses any drift after the release"
- "bash scripts/check-phase19.7.sh --all passes on a Docker host with every stage PASS"
- "19.7-SECURITY-REVIEW.md corrects the verdicts the code review disproved (T-19.7-22 first, plus T-19.7-03, -08, -17, -18, -23, -31, -35 and cross-cutting findings 1 and 6 on link proof and logs), records T-19.7-42..T-19.7-65 with implementation, test and status, and shows for the three critical findings that the pinning test fails with the fix reverted; 19.7-SECURITY.md reports threats_open 0"
- "19.7-VALIDATION.md has a row for every new behaviour with its final command, and deferred-items.md records every finding not fixed (B-IN-05, B-IN-06) with its reason plus the release order and the local-only wiring"
artifacts:
- path: "scripts/check-phase19.7.sh"
provides: "the extended fail-closed gate with the unreleased-pin policy and workspace checks"
contains: "PHASE197_RELEASED"
- path: "../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go"
provides: "E-2's acceptance list as tests"
contains: "Missing required scope: servers:write"
- path: "../sm-demo-app/mcp_revocation_e2e_test.go"
provides: "the end-to-end revocation matrix"
contains: "func TestMCPRevocationEndToEnd("
- path: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
provides: "the corrected review and the gap-closure evidence"
contains: "T-19.7-65"
key_links:
- from: "scripts/check-phase19.7.sh"
to: "../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go"
via: "USER_TESTS requires TestMemberTokenScopes; USER_SHORT_TESTS requires TestMemberTokenScopesBoot"
pattern: "TestMemberTokenScopes"
- from: "scripts/check-phase19.7.sh"
to: "modules/pier/reaper_test.go"
via: "GO_TESTS and GO_TAGGED_TESTS require TestSessionReaper"
pattern: "TestSessionReaper"
- from: ".planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md"
to: "19.7-0{6,7,8}-PLAN.md threat registers"
via: "one row per T-19.7-42..65 with implementation and test"
pattern: "T-19.7-4"
---
<objective>
This is the gap closure's last plan; by project rule, unit tests come last. It:
- adds a named test for every fix of plans 06-08 and for both escalations, including E-2's acceptance list verbatim;
- extends `scripts/check-phase19.7.sh` so the new tests are required and the gate cannot pass on unreleased plugin drift or a stray local workspace;
- adds the `19.7-VALIDATION.md` rows;
- corrects `19.7-SECURITY.md` and `19.7-SECURITY-REVIEW.md`, where the code review proved two verdicts wrong: T-19.7-22's link proof was marked verified, and the review's claim that the link code never reaches a log was false. It also records the new threats T-19.7-42..T-19.7-65, among them the E-2 scopes and the `auth_time` claim;
- updates `deferred-items.md`.
**Planner decisions recorded here (P-40, P-41):**
- **P-40 (gate policy for the gap closure):**
- **Workspace check.** Before running anything in a repository, the gate checks how it resolves the framework (`go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{end}}' git.golem15.com/golem15/summercms`). Every repository must resolve it to this checkout. sm-oauthserver-plugin must also resolve `sm-user-plugin` to `$USER_PLUGIN`. A mismatch is a `refuse:` line naming both paths. This replaces trust in git-ignored go.work files.
- **Unreleased pins.** `demo_pins` keeps accepting test-only drift, and also accepts a plugin submodule ahead of its pin when its HEAD descends from the pin and is the HEAD of, or an ancestor of, the canonical checkout: `$USER_PLUGIN`, `$OAUTHSERVER_PLUGIN` or `$JOURNAL_PLUGIN` by submodule name. That case prints `pins: <name> is ahead of its pin by unreleased commits (release after verification)`.
- **After the release.** With `PHASE197_RELEASED=1` any drift fails, so the gate proves the bumped pins after the user's release.
- **vue-demo-app.** The consumers stage runs the demo site's vitest run with `app/lib/linkCode.test.ts` required (the vitest detector).
- **Consumers on the unreleased user plugin.** fonoteka.go and sm-bm-app are also built through a scratch copy of their own go.work that swaps their user checkout for `$USER_PLUGIN` (`consumer_with_user_plugin`), proving that the plugin's changes are additive for its consumers before any tag. Their own go.work files are never edited.
- **Docs.** The docs stage checks cooler's two doc comments (A-IN-02).
- **P-41 (revert-check, the lesson of T-19.7-22):** for each of the three critical findings, the security review records that the pinning test fails with the fix reverted and passes at HEAD:
- A-CR-01 → `TestSessionReaper`;
- C-CR-01 → `TestMCPVerifierRevocation`;
- C-CR-02 → `TestBackendLinkAuthTime`.
Method: a temporary `git worktree add` under the session scratch directory at HEAD, then `git revert --no-commit <fix sha>`, run the single test, and record FAIL. Remove the worktree afterwards. Nothing is committed from the worktree.
Purpose: `/msd-verify-work` has one green gate covering the gap closure, and the security documents state only what a test proves.
Output: named tests in six repositories (framework, user plugin, oauthserver, Journal, the demo and vue-demo-app), the extended gate, and the corrected and extended planning documents.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/19.7-generic-mcp-module/19.7-GAP-INPUTS.md
@.planning/phases/19.7-generic-mcp-module/19.7-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
@.planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
@.planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
@.planning/phases/19.7-generic-mcp-module/deferred-items.md
@.planning/phases/19.7-generic-mcp-module/19.7-05-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-06-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-07-SUMMARY.md
@.planning/phases/19.7-generic-mcp-module/19.7-08-SUMMARY.md
@CLAUDE.md
</context>
<project_rules>
- Tests, the gate and the planning documents only. A production defect found here gets its own `fix(<module or plugin>)` commit in the owning repository, before the test commit, and is named in the SUMMARY (as in 19.6.1-03 and 19.7-05). A test commit never touches a non-test .go file.
- Postgres tests:
- no `t.Parallel`; unique seeded rows and cleanup;
- a Postgres subtest is created only when not `-short` (no `t.Skip`), so the gate's skip refusal stays meaningful;
- the user plugin runs with `GOWORK=off`;
- Docker through `sudo -u golem` if the socket is denied.
- Gate entries are `<package>:<TestName>` and list every required test by name. Detectors refuse skipped, zero-match, "no tests to run" and missing names. Every new detector or policy gets a planted `--self-test` case.
- Tests and docs in framework files name no consuming application (the docs stage scans the phase's test files: append every new framework test file to `PHASE_TEST_FILES`). Plugin and demo tests may use `servers:write` and `servers:read`, because those are E-2's acceptance values.
- Stage by explicit path in every repository. One logical change per commit, no co-author tags. Planning documents (VALIDATION, SECURITY, SECURITY-REVIEW, deferred-items, SUMMARY) go in their own commits. In sm-demo-app, never stage `main.go` or a plugin submodule pointer.
- Every commit is self-contained: the repository builds, vets and passes its `-short` tests at each commit, and the gate script works at each of its commits (the arrays name only tests that exist in that commit or in an earlier one).
- Release rule: no tag, push or deploy in this plan.
</project_rules>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: the gate's workspace check and unreleased-pin policy run end to end, proven by --self-test, with the first gap-closure tests (TestSessionReaper, TestAuthTime) required in the go stage</name>
<files>scripts/check-phase19.7.sh, modules/pier/reaper_test.go, modules/pier/export_test.go, modules/bouncer/auth_time_test.go</files>
<read_first>
- scripts/check-phase19.7.sh in full: arrays, `detect_go`, `detect_vitest`, `go_named`, `go_pkg_named`, `run_self_test`, `run_plugins`, `run_consumers`, `demo_pins`, `run_docker`, `run_docs`, `run_all`
- 19.7-08-SUMMARY.md (the oauthserver go.work target, the detached demo submodule HEADs, the new commit shas)
- modules/pier/reaper_smoke_test.go and export_test.go (plan 06 seams `ReapNow`, `LocalSessions`, `AdvanceStoreClock`); modules/pier/helpers_test.go (`twoInstances`, `sdkClient`)
- modules/bouncer/auth_time_smoke_test.go (plan 07) and mint.go, refresh.go, jwt.go
</read_first>
<action>
**Gate (P-40).**
- Add `workspace_check DIR [EXPECT_USER_PLUGIN]`. It runs `go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{end}}' git.golem15.com/golem15/summercms` in DIR (with `GOWORK=off` for the user plugin, as the plugins stage runs it), resolves both sides with `realpath`, and refuses when the answer is not `$ROOT`. For sm-oauthserver-plugin it does the same for `git.golem15.com/golem15/sm-user-plugin` against `$USER_PLUGIN`.
- Call it at the top of `run_plugins` for all three plugins, of `run_consumers` for the demo, and of `run_docker` for the user plugin, oauthserver and the demo.
- Add `consumer_with_user_plugin APP`. It writes a scratch go.work (under `mktemp -d`, removed on exit) that copies the application's own go.work and rewrites:
- every `use` and `replace` path to absolute;
- the application's `./plugins/golem15/user` `use` entry to `$USER_PLUGIN`;
- a `replace git.golem15.com/golem15/summercms => $ROOT`.
It checks that `go list -m -f '{{.Dir}}' git.golem15.com/golem15/sm-user-plugin` answers `$USER_PLUGIN`, then runs `go build ./...` with that `GOWORK`.
- `run_consumers` runs it for `$FONOTEKA` and `$BM_APP` after their plain builds, proving that the unreleased user plugin compiles with its consumers. The applications' own go.work files are never edited.
- Add a self-test case: a planted go.work rewritten as expected.
- Extend `demo_pins`:
- After the test-only check fails, map the submodule name to its canonical checkout (`plugins/golem15/user` → `$USER_PLUGIN`, `plugins/golem15/oauthserver` → `$OAUTHSERVER_PLUGIN`, `plugins/golem15/journal` → `$JOURNAL_PLUGIN`).
- Accept when the submodule HEAD descends from its pin and the canonical checkout's HEAD equals it or descends from it (`git -C <canonical> merge-base --is-ancestor <submodule HEAD> HEAD`). Print the `unreleased commits` line.
- With `PHASE197_RELEASED=1`, any drift fails before these checks.
- Document `PHASE197_RELEASED` in the header's environment list.
- Add `--self-test` cases:
- planted `go list` answers for the workspace check (match, mismatch);
- planted `check-pins.sh` lines for the three pin outcomes (test-only, unreleased accepted, refused), plus the released-mode refusal.
**First required tests.**
- `modules/pier/reaper_test.go`, `TestSessionReaper` (A-CR-01, A-IN-01, A-IN-06), with subtests:
- memory store: idle past TTL → reaped, `ss.Wait` returns, the 404 on the next call, `expired` audit line captured through a test slog handler;
- Redis (`twoInstances`, `miniredis.FastForward`): the instance that did not serve the last request keeps its copy only while the record lives;
- a store error keeps the copy;
- a GET stream whose record vanished is cancelled by the re-check (`SetRecheck`), with no `ended` event delivered;
- `serveEphemeral` calls are logged as `ephemeral`.
Add any seam it needs to export_test.go.
- `modules/bouncer/auth_time_test.go`, `TestAuthTime`:
- mint carries `auth_time == iat` for both audiences;
- `Refresh`, `RefreshAudience` and `RefreshAudienceFor` carry it unchanged across two rotations;
- a legacy token without the claim refreshes without one;
- `VerifyAuthTime` refuses a bad signature, the wrong audience and an expired token, and accepts a frontend token without `aud` as `VerifyClaims` does;
- `ClaimAuthTime == "auth_time"`.
- Append `./modules/pier:TestSessionReaper` to `GO_TESTS` and `GO_TAGGED_TESTS`, `./modules/bouncer:TestAuthTime` and the plan 06 and 07 smokes (`./modules/pier:TestPierReaperSmoke`, `./modules/bouncer:TestAuthTimeSmoke`) to `GO_TESTS`, and the new files to `PHASE_TEST_FILES`.
</action>
<verify>
<automated>bash scripts/check-phase19.7.sh --self-test && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --plugins && bash scripts/check-phase19.7.sh --consumers</automated>
<fails_when>non-zero exit, or a stage line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "PHASE197_RELEASED" scripts/check-phase19.7.sh` prints at least 2 and `grep -c "workspace_check" scripts/check-phase19.7.sh` prints at least 4
- `grep -c "unreleased commits" scripts/check-phase19.7.sh` prints at least 1 and `grep -c "consumer_with_user_plugin" scripts/check-phase19.7.sh` prints at least 3 (definition and two calls)
- `git -C ../fonoteka.go status --short go.work | wc -l` and `git -C ../sm-bm-app status --short go.work | wc -l` print 0 after the run
- `grep -c "TestSessionReaper\|./modules/bouncer:TestAuthTime" scripts/check-phase19.7.sh` prints at least 3
- `grep -c "func TestSessionReaper(" modules/pier/reaper_test.go` prints 1 and `grep -c "func TestAuthTime(" modules/bouncer/auth_time_test.go` prints 1
- `bash scripts/check-phase19.7.sh --self-test` prints PASS for every planted case, including the workspace and pin policy cases, and exits 0
- `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` exits non-zero while the demo's submodules are ahead of their pins (proves strict mode; run once, not part of the verify chain)
</acceptance_criteria>
<done>The gate verifies where each repository resolves its dependencies, reports unreleased plugin commits honestly and turns strict after the release; the first two gap-closure tests are required by name and green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Framework and admin SPA tests for every fix of plans 06 and 07</name>
<files>modules/pier/reserve_test.go, modules/pier/lock_scope_test.go, modules/pier/widening_test.go, modules/pier/stateless_fanout_test.go, modules/pier/personal_binding_test.go, modules/pier/dispatch_hardening_test.go, modules/pier/catalog_path_fields_test.go, modules/pier/prompt_ceiling_test.go, modules/pier/token_events_test.go, modules/pier/export_test.go, modules/pact/frontend_token_scopes_test.go, modules/towel/client_ip_test.go, modules/cabana/account_contributions_hardening_test.go, modules/cabana/record_action_prompt_localized_test.go, modules/cabana/token_scope_name_test.go, modules/cabana/account_token_events_test.go, modules/cabana/token_resolver_remote_test.go, admin/tests/app/hotkeysEditing.test.ts, admin/tests/account/AccountContributions.test.ts, scripts/check-phase19.7.sh</files>
<behavior>
- TestSessionReserve (A-WR-03):
- 50 concurrent initialize POSTs from one binding open exactly `max_per_binding` sessions on the memory store and on miniredis;
- the rest answer 429;
- a forced connect failure releases its slot;
- a store without `reserver` falls back to Count and Put.
- TestLockScope (A-WR-07): a guard provider that blocks in `MCPAllows` during one client's initialize (a channel) does not delay another client's call on an existing session, a DELETE, or a stateless request (each finishes within a short deadline while the first is blocked).
- TestScopeWidening (A-WR-04):
- a binding first seen with `read` and then with `read write` lists the write tool on the next request on the stateless path and on the stateful path;
- adding a backend subject (link) adds the backend tools without any fan-out event;
- narrowing still hides the tools.
- TestStatelessFanOut (A-WR-05, A-WR-06):
- on miniredis an instance that has served only stateless requests receives a revoked event published by the other instance and evicts its entry;
- a `subscriptions/listen` stream ends within the re-check interval once its bearer stops resolving;
- an LRU eviction at capacity closes the evicted server's sessions.
- TestPersonalTokenBinding (A-WR-09): a resolver answering TokenID 0 gives 401 at /mcp; two users' tokens get distinct bindings, and one cannot DELETE the other's session.
- TestDispatchHardening (A-WR-01, A-WR-02 runtime, A-WR-08, A-IN-04, A-IN-05, A-IN-08):
- `..`, `.`, `a/../b`, an empty segment and `a/b` in a single segment are refused and never reach the handler (counter);
- a missing path value is refused;
- a provider error with a pgx-like message yields the fixed text, and the message appears only in the captured log;
- `9007199254740993` reaches the route unchanged in the path and the query;
- the inner request has the outer Host, X-Forwarded-Proto and X-Forwarded-Host and still no Authorization or Cookie;
- a frontend tool whose provider answers `Guards: ["backend"]` or a Backend principal is refused.
- TestCatalogPathFields (A-WR-02 boot, A-IN-07):
- a `path:"id"` field with `omitempty` fails boot naming the field;
- the same field without it compiles;
- an embedded struct on a GET tool fails boot, and on a POST tool compiles.
- TestPromptWriteCeiling (C-IN-01): a `Writes: true` prompt is listed for a read+write grant and hidden from a read-only grant; a prompt without Writes is listed for both.
- TestTokenScopedRevoke (B-WR-04, C-IN-02 framework):
- a revoked event with TokenID closes only the session opened with that personal token, while another token's session and an OAuth session of the same user stay;
- with ClientID only that client's OAuth sessions close;
- with neither all close;
- the fields survive the Redis round trip between two instances.
- TestResolveClientAddress (B-IN-04): a resolver sees `towel.ClientIP` equal to the client address (RemoteAddr host; the trusted-proxy answer through a host that implements ClientIP).
- TestFrontendTokenScopes (pact): `ValidTokenScopeName` table (accepts `read`, `servers:write`, `a_b-c:d`; refuses ``, `Read`, `servers:`, `:x`, `a:b:c`, `*`, `with space`); a sample plugin is discovered as `HasFrontendTokenScopes`.
- TestTokenScopeNameParity (cabana): cabana's plugin scope validation accepts and refuses exactly the pact table's names.
- TestClientIP (towel): round trip, absent value, empty string.
- TestAccountContributionURL (cabana, -short, dispatch-marker harness):
- `javascript:alert(1)`, `data:text/html,x`, `//evil.example/x` and `vbscript:` are dropped (`url` empty) and logged without the URL;
- `https://site/link-admin#code=x` and `/relative` pass.
- TestAccountContributionsIsolation (B-WR-06, B-IN-03):
- plugin A's Status error gives A `error: true` and empty status while plugin B's section and actions are intact, with status 200;
- an Applies error drops only that action;
- a dynamic status text is not looked up as a phrase key (no missing-key warning).
- TestRecordActionPromptLocalized (B-IN-02): a non-string payload answers 422 with the `action_prompt_invalid` text in en and pl.
- TestAccountTokenRevokeEvent (cabana, Postgres): revoking one `g15_` token fires one revoked event whose TokenID is the row id; nothing per-admin.
- TestResolveBackendTokenRemote (cabana, Postgres): a failed and a successful resolve log `remote` from `towel.ClientIP`.
- Vitest hotkeysEditing.test.ts (B-WR-01, B-WR-02):
- a keydown of `?` or Escape from an input inside an open shadow root, and from a custom element in a form (closed root), triggers no shortcut and is not prevented;
- Ctrl+S with a TaglistField draft saves the record with the draft tag (the posted body contains it);
- Ctrl+Enter does the same before closing.
- Vitest AccountContributions.test.ts (extend) (B-WR-03, B-WR-06, B-IN-01):
- a `javascript:` URL answer renders no link and never calls `window.open`;
- a section with `error: true` shows the section_error text;
- a 500 on GET shows the error toast and keeps the earlier sections;
- the confirm and stale texts are the myaccount ones.
</behavior>
<read_first>
- The plan 06 and 07 SUMMARYs (final identifiers, seams and any test they adjusted)
- modules/pier/helpers_test.go, export_test.go, sessions_test.go, fanout_test.go, dispatch_test.go, catalog_test.go (extend the fakes; never duplicate them)
- modules/cabana/account_contributions_test.go, dispatch_marker_test.go, administrators_harness_test.go (the -short marker harness and the Postgres `newScreensEnv`)
- admin/tests/app/hotkeys.test.ts, admin/tests/account/AccountContributions.test.ts, admin/tests/form/RecordActions.test.ts, admin/tests/helpers.ts
- scripts/check-phase19.7.sh (GO_TESTS, GO_TAGGED_TESTS, DOCKER_TESTS, ADMIN_FILES, PHASE_TEST_FILES)
</read_first>
<action>
Write the tests in the behavior block, under exactly those names, in the files listed. Subtests carry the case names.
Work red-first where the behaviour is not yet pinned: most pass at once because plans 06 and 07 built the behaviour. A test that fails because the code is wrong gets a `fix(<module>)` commit before the test commit.
Gate arrays:
- `GO_TESTS`: every -short name, as `<package>:<Name>`.
- `GO_TAGGED_TESTS`: the pier names.
- `DOCKER_TESTS`: `./modules/cabana:TestAccountTokenRevokeEvent` and `./modules/cabana:TestResolveBackendTokenRemote` (their -short part returns before creating Postgres subtests).
- `ADMIN_FILES`: `tests/app/hotkeysEditing.test.ts`.
- `PHASE_TEST_FILES`: every new framework test file.
</action>
<verify>
<automated>go vet ./... && go test -count=1 -short -race ./modules/pier/... && go test -count=1 -short ./modules/pier/... ./modules/pact/... ./modules/towel/... ./modules/cabana/... ./modules/bouncer/... && bash scripts/check-phase19.7.sh --go && bash scripts/check-phase19.7.sh --admin && bash scripts/check-phase19.7.sh --docs</automated>
<fails_when>non-zero exit, a race report, "FAIL" in the output, or a gate line printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- `grep -h "^func Test" modules/pier/reserve_test.go modules/pier/lock_scope_test.go modules/pier/widening_test.go modules/pier/stateless_fanout_test.go modules/pier/personal_binding_test.go modules/pier/dispatch_hardening_test.go modules/pier/catalog_path_fields_test.go modules/pier/prompt_ceiling_test.go modules/pier/token_events_test.go | grep -c "TestSessionReserve\|TestLockScope\|TestScopeWidening\|TestStatelessFanOut\|TestPersonalTokenBinding\|TestDispatchHardening\|TestCatalogPathFields\|TestPromptWriteCeiling\|TestTokenScopedRevoke\|TestResolveClientAddress"` prints 10
- `grep -c "func TestFrontendTokenScopes(" modules/pact/frontend_token_scopes_test.go`, `grep -c "func TestClientIP(" modules/towel/client_ip_test.go` and `grep -c "func TestTokenScopeNameParity(" modules/cabana/token_scope_name_test.go` each print 1
- `grep -c "TestAccountContributionURL\|TestAccountContributionsIsolation\|TestRecordActionPromptLocalized\|TestAccountTokenRevokeEvent\|TestResolveBackendTokenRemote" scripts/check-phase19.7.sh` prints at least 5
- `grep -c "hotkeysEditing.test.ts" scripts/check-phase19.7.sh` prints at least 1
- `bash scripts/check-phase19.7.sh --go` and `--admin` exit 0
</acceptance_criteria>
<done>Every framework and SPA fix of plans 06 and 07 is pinned by a required test; the go, admin and docs stages are green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Plugin, site and demo tests for plan 08 (E-2's acceptance verbatim, E-1, the link proof, revocation), required by the gate; the whole gate green</name>
<files>../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go, ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go, ../sm-demo-app/plugins/golem15/user/access_revocation_test.go, ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go, ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go, ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go, ../sm-demo-app/plugins/golem15/user/api_token_events_test.go, ../sm-oauthserver-plugin/mcp_revocation_test.go, ../sm-journal-plugin/mcp_prompt_writes_test.go, ../sm-demo-app/mcp_revocation_e2e_test.go, ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts, ../sm-demo-app/vue-demo-app, scripts/check-phase19.7.sh</files>
<behavior>
- TestMemberTokenScopes (user plugin, Postgres). A test plugin declares `servers:read` and `servers:write`. Each E-2 acceptance item is a subtest whose name quotes it:
- "A plugin that declares servers:write can mint a usr_ token with that scope through POST /tokens, and a route with middleware.Scope(\"servers:write\") accepts it": POST `/_user/api/v1/tokens` with `{"name":"agent","scopes":["servers:write"]}` → 201 and a `usr_` token; `PUT /_test/servers/1/components/report` behind `APITokenGuard` and `middleware.Scope("servers:write")` → 200.
- "A token without the scope gets 403 \"Missing required scope: servers:write\"": a `["read"]` token on that route → 403 with exactly that message.
- "An undeclared scope still answers 422 at mint time": `["servers:delete"]` → 422 with `scopes: ["The selected scope is invalid."]`.
- "Listing returns them in scopes": `GET /tokens` shows `["servers:write"]` in the token's `scopes`.
- "The discovery answer lists the built-ins and the declared scopes with labels": `GET /tokens/scopes` behind `jwt.auth` → `read`, `write`, `ai` (built_in true, labels translated in en and pl) then `servers:read`, `servers:write` with their labels; no session → 401.
- "Scopes are scoped to the application": a second application (app B), booted in the same test binary without the declaring plugin, answers 422 `The selected scope is invalid.` to POST /tokens with `["servers:write"]`. `MintAPITokenFor(appB, ...)` refuses it, and `MintAPITokenFor(appA, ...)` accepts it.
- The legacy `MintAPIToken` accepts `servers:write` through the process-wide fallback and refuses a never-declared name. The fallback is cleared with `ResetDeclaredAPITokenScopes()` in `t.Cleanup`, so test order cannot leak declarations.
- TestMemberTokenScopesBoot (user plugin, -short): "Two plugins declaring the same scope name … fails boot" (the error names both plugin ids); "a plugin redeclaring read, write or ai fails boot" (one subtest each); a bad name and an empty label fail; a `::` label naming a missing phrase key fails at Boot.
- TestBackendLinkAuthTime (user plugin, Postgres, C-CR-02):
- login, then request, then refresh, then confirm → 409 stale_session;
- request, then login, then confirm → 200;
- login, then request, then login again → 200;
- a hand-made token without auth_time → 409;
- a refreshed token after a post-request login keeps working (auth_time carried);
- another user's token → 401.
- TestBackendLinkShowPost (user plugin, Postgres, C-WR-01, C-IN-06):
- POST show answers like GET (200 data shape, 404 for an unknown, used or expired code), and both carry Cache-Control no-store;
- the My account action URL is `<frontend_url>/link-admin#code=<64 hex>` with no `?`;
- `golem15.user.backend_links.page_path: /pl/polacz` changes the path;
- a value without a leading slash gets one.
- TestAccessRevocation (user plugin, Postgres, C-CR-01):
- the ban bulk action, ResetPassword, ChangePassword and the admin password set each fire one AccessRevokedEvent in the write's transaction, with the right ids, Before and Reason;
- for ResetPassword and the admin password set, the stored `tokens_valid_after` is whole seconds while `Before` is the untruncated instant (`Before` is not before the stored value and has a sub-second part when the clock has one). ChangePassword passes the stored cutoff plus one second;
- the ban wrapper fires only for newly banned ids: selecting an already banned user together with an unbanned one fires one event naming only the second;
- each fires MCPSubjectRevoked per user only after commit;
- a failing listener rolls the password reset back;
- `LinkedBackendUser` answers false for a banned linked user and true again after unban.
- TestUserMCPGuardClientIP (C-IN-04): `MCPResolvePersonalToken` with `towel.WithClientIP` stamps `last_used_ip`; without it, the column is left as it was.
- TestAPITokenRevokeEvent (C-IN-02): `DELETE /tokens/{id}` fires one revoked event with that TokenID.
- TestOrganisationLogo (user plugin, Postgres, E-1):
- the Organisations form schema has a `logo` fileupload next to `avatar`;
- uploading through the admin form API stores a `system_files` row with field `logo`;
- `OrganisationLogoFields` answers `has_logo true`, the disk name and a URL;
- without a logo it answers `has_logo false` and nils;
- the avatar still works;
- deleting the organisation removes the logo attachment as it does the avatar.
- TestMCPVerifierRevocation (oauthserver, Postgres, C-CR-01):
- a token created before `tokens_valid_after` → ErrMCPTokenInvalid;
- the same-second case: with `tokens_valid_after` stored as 12:00:05 (whole seconds), a token created at 12:00:05.300 is refused by the verifier and by the refresh rotation even with no revoke listener registered, and a token created at 12:00:06.000 is accepted;
- `OnAccessRevoked` with `Before` = 12:00:05.700 revokes the 12:00:05.300 token (untruncated revoke) and leaves one created at 12:00:05.900;
- a banned user → ErrMCPTokenInvalid, and valid again after unban for a token created after the cut-off;
- the MCP refresh rotation refuses a banned user, a pre-cutoff refresh token and a deleted user.
- TestMCPGrantRevocation (oauthserver, Postgres, C-CR-01, C-WR-02, C-IN-02):
- AccessRevokedEvent revokes MCP access and refresh tokens created before Before, and OIDC access and refresh tokens (and the OIDC consent row) created before Before, and leaves later ones of both kinds;
- RemoveLink and a new SetLink revoke only `backend`-scoped MCP tokens of that user and fire revoked events with their ClientIDs after commit;
- the authorization revoke route fires with ClientID.
- TestJournalPromptWrites (Journal, -short): `draft_post` declares `Writes`; through pier's catalog (the TestJournalMCP harness) a read-only identity's prompts/list omits `journal_draft_post` and a read+write one lists it.
- TestMCPRevocationEndToEnd (demo, tagged, Postgres) runs through the SDK client:
- (a) a linked user with a `read write backend` grant is banned through the admin's bulk ban route: the next call is 401, the session is gone, and the refresh grant is refused;
- (b) unlinking revokes the backend grant: the next call is 401, and a fresh consent without a link has no `backend`;
- (c) a read-only grant lists no `journal_draft_post`;
- (d) with `mcp.allow_personal_tokens: true`, revoking one `usr_` token ends only its session while the same user's OAuth session keeps working.
- linkCode.test.ts (vue-demo-app): `readLinkCode('#code=<64 hex>')` returns it; uppercase, short, extra parameters and an empty hash give ''; `isLinkCode` is exact.
</behavior>
<read_first>
- The plan 08 SUMMARY (final identifiers, commit shas, the go.work target, the submodule HEADs)
- The existing harnesses: user plugin backend_link_test.go and api_tokens_test.go (admin and session helpers), mcp_guard_test.go; oauthserver mcp_verifier_test.go and harness_test.go; Journal mcp_test.go (`TestJournalMCP` compiles the declaration through pier); demo mcp_e2e_test.go (SDK client, consent helper, `internal/demotest`), testmain_test.go
- The plan 08 smokes (`TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke`, `TestMCPRevocationSmoke`): lift their seeds into the shared helpers of each repository instead of duplicating them
- scripts/check-phase19.7.sh (USER_TESTS, OAS_TESTS, JOURNAL_SHORT_TESTS, DEMO_TESTS, `detect_vitest`)
- 19.7-GAP-INPUTS.md E-2 "Acceptance" (the subtest names quote it)
</read_first>
<action>
Write the tests in the behavior block under exactly those names, one commit per repository. Fix any production defect first in its own `fix(` commit in the owning repository.
Gate:
- Add a `USER_SHORT_TESTS=(.:TestMemberTokenScopesBoot)` array, run in `run_plugins` with `GOWORK=off go_pkg_named "$USER_PLUGIN" -short`.
- Append to `USER_TESTS`: `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestMemberTokenScopes`, `TestOrganisationLogo`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestBackendLinkAuthTimeSmoke`, `TestMemberTokenScopesSmoke`.
- Append to `OAS_TESTS`: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`, `TestMCPRevocationSmoke`.
- Append to `JOURNAL_SHORT_TESTS`: `.:TestJournalPromptWrites`.
- Append to `DEMO_TESTS`: `TestMCPRevocationEndToEnd`.
- Add a `VUE_FILES=(app/lib/linkCode.test.ts)` array. `run_consumers` runs `pnpm --dir "$DEMO_APP/vue-demo-app" run test` through `detect_vitest` with those files required.
- Add the docs-stage cooler check: `go doc ./modules/cooler KeyPrefix` line 4 starts with `KeyPrefix returns`, and `go doc -u ./modules/cooler keyPrefix` shows its own comment.
In sm-demo-app:
- Commit the e2e test only.
- Commit `app/lib/linkCode.test.ts` in vue-demo-app.
- Commit the vue-demo-app pointer bump separately, staging only `vue-demo-app`.
Then run `bash scripts/check-phase19.7.sh --all` on a Docker host.
</action>
<verify>
<automated>bash scripts/check-phase19.7.sh --all</automated>
<fails_when>non-zero exit, any stage printing FAIL, "refuse:" or "missing-named", or "NOT RUN docker" while a Docker daemon is reachable (run on a Docker host, through sudo -u golem if the socket is denied)</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "Missing required scope: servers:write" ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints at least 1, and `grep -c "The selected scope is invalid." ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints at least 1
- `grep -c "func TestMemberTokenScopes(\|func TestMemberTokenScopesBoot(" ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go` prints 2
- `grep -c "func Test" ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go ../sm-demo-app/plugins/golem15/user/access_revocation_test.go ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go ../sm-demo-app/plugins/golem15/user/api_token_events_test.go` prints at least 1 per file
- `grep -c "func TestMCPVerifierRevocation(\|func TestMCPGrantRevocation(" ../sm-oauthserver-plugin/mcp_revocation_test.go` prints 2; `grep -c "func TestJournalPromptWrites(" ../sm-journal-plugin/mcp_prompt_writes_test.go` prints 1; `grep -c "func TestMCPRevocationEndToEnd(" ../sm-demo-app/mcp_revocation_e2e_test.go` prints 1
- `grep -c "USER_SHORT_TESTS\|VUE_FILES" scripts/check-phase19.7.sh` prints at least 4
- `bash scripts/check-phase19.7.sh --all` exits 0 on a Docker host with PASS for self-test, go, plugins, consumers, admin, docs, marker and docker
</acceptance_criteria>
<done>Every plugin, site and demo fix of plan 08 and both escalations are pinned by required tests (E-2 item by item), and the whole phase gate is green.</done>
</task>
<task type="auto">
<name>Task 4: Validation rows, the corrected and extended security review and contract (revert-checks for the critical findings), deferred items</name>
<files>.planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md, .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md, .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md, .planning/phases/19.7-generic-mcp-module/deferred-items.md</files>
<read_first>
- 19.7-SECURITY-REVIEW.md (Method, Gaps fixed, Threat register rows T-19.7-03, -08, -17, -18, -22, -23, -31, -35, Cross-cutting findings 1 and 6, Accepted risks, Residual items) and 19.7-SECURITY.md (frontmatter, register, Accepted Risks Log, Audit Trail)
- The `<threat_model>` blocks of 19.7-06, 19.7-07, 19.7-08 and this plan (T-19.7-42..T-19.7-65)
- 19.7-REVIEW.md (finding ids for the corrections) and the 06, 07 and 08 SUMMARYs (commit shas per finding)
- 19.7-VALIDATION.md (row format) and deferred-items.md (entry format, open items to update)
</read_first>
<action>
**Revert-checks (P-41).** For each pair below:
1. Create a scratch worktree of the owning repository at HEAD under the session scratch directory: `git -C <repo> worktree add <scratch>/<name> HEAD`.
2. Run `git revert --no-commit <fix sha>` there.
3. Run the single test: framework with `go test`; user plugin with `GOWORK=off`. The user plugin's relative replace resolves only from its real location, so in its worktree use `go mod edit -replace` to the absolute framework path (never committed), or `go work` in the scratch directory.
4. Record FAIL with the failing line.
5. Remove the worktree (`git worktree remove --force`).
The pairs:
- A-CR-01 → `TestSessionReaper`;
- C-CR-01 → `TestMCPVerifierRevocation` (the oauthserver verifier fix);
- C-CR-02 → `TestBackendLinkAuthTime`: revert the user plugin confirm change. The framework auth_time cannot be "reverted" from the plugin worktree, so revert the plugin commit only.
At HEAD all three pass (Task 3's gate run).
**19.7-SECURITY-REVIEW.md:** add a section "Gap closure (plans 06-09, 2026-10-23)" with:
- **Corrections**, a table of each earlier verdict the code review disproved: threat id, earlier verdict, finding, what was wrong, fix commit(s), pinning test. The rows:
- T-19.7-22: the link proof compared `iat`, which refresh renews (C-CR-02); it was wrongly marked verified.
- Cross-cutting finding 1: the link proof.
- Cross-cutting finding 6: the claim that the link code never reaches a log was false; it reached nginx and site request logs and browser history (C-WR-01). Describe the old claim in your own words; do not quote its sentence.
- T-19.7-23: code leakage.
- T-19.7-03 and T-19.7-17: the mux cleaning was not part of the in-process chain (A-WR-01, A-WR-02).
- T-19.7-18: the session leak and the cap race (A-CR-01, A-WR-03).
- T-19.7-08: the `javascript:` URL sink (B-WR-03).
- T-19.7-31: grant re-targeting after re-linking (C-WR-02).
- T-19.7-35: grants surviving ban and reset (C-CR-01).
- Rewrite cross-cutting findings 1 and 6 in place with the corrected text and a "corrected in gap closure" marker.
- **Revert-checks:** a table with repository, fix sha, test, FAIL line with the fix reverted, PASS at HEAD.
- **New threats:** register rows for T-19.7-42..T-19.7-65 in the existing evidence-table format (Mitigation as built, Implementation file:line, Test, Status). Each has a status of verified or fixed in <sha>. The E-2 rows (T-19.7-52, T-19.7-60) and the auth_time rows (T-19.7-51, plus T-19.7-22's correction) cite the decision note and the E-2 acceptance tests.
- **Method:** add one step: a high or critical control is "verified" only with a test that fails without it; for the three critical findings this is shown by revert-check.
**19.7-SECURITY.md:**
- The register gains T-19.7-42..T-19.7-65.
- The corrected rows get their new status ("fixed in <sha>").
- The Audit Trail gets a gap-closure entry.
- `threats_open` stays 0 only if every high threat is verified or fixed. Otherwise set the real count and stop with a blocking note in the SUMMARY.
**19.7-VALIDATION.md:** one row per new behaviour (every test name of Tasks 1-3) with its final command, File Exists ✅ and Status ✅ green after the Task 3 gate run. Keep `nyquist_compliant: true`.
**deferred-items.md:**
- New entries with reasons:
- B-IN-05: the prompt dialog closes on a 422. The contract text was corrected in plan 07; keeping the dialog open changes the shared confirm primitive used by every action.
- B-IN-06: no fix required; the visible link is the popup-blocker fallback.
- OIDC grants on ban or password reset: decided by the user on 2026-10-23 (both MCP and OIDC grants are revoked); record it as resolved, with the test that pins it.
- The local oauthserver go.work retarget.
- The demo's oauthserver and journal submodules detached at unreleased commits, plus the user submodule pointer, all waiting for the release.
- The release order:
1. framework v0.5.0;
2. sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags requiring it;
3. the demo, fonoteka.go and sm-bm-app bumps, with vue-demo-app pushed before sm-demo-app;
4. sm-servers-app picks up E-1 and E-2 after the user plugin tag;
5. then `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers`.
- Update the status of earlier entries the gap closure resolved or superseded, for example the plan 05 test commits that are now part of the gap-closure release.
Commit each document as a planning doc.
</action>
<verify>
<automated>grep -q "threats_open: 0" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md && for n in $(seq 42 65); do grep -q "T-19.7-$n" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && grep -q "T-19.7-$n" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md || { echo "missing T-19.7-$n"; exit 1; }; done && grep -q "Revert-check" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md && grep -q "B-IN-05" .planning/phases/19.7-generic-mcp-module/deferred-items.md && grep -q "TestMemberTokenScopes" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md && bash scripts/check-phase19.7.sh --all</automated>
<fails_when>non-zero exit, a "missing T-19.7-NN" line, a missing section or row, or any gate stage printing FAIL, "refuse:" or "missing-named"</fails_when>
</verify>
<acceptance_criteria>
- Every id T-19.7-42 through T-19.7-65 appears in both security documents, and each row in 19.7-SECURITY-REVIEW.md names a file:line and a test and has the status verified or fixed in <sha>
- The T-19.7-22 row of 19.7-SECURITY-REVIEW.md no longer has the bare status `verified`: `grep "^| T-19.7-22 " .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md | grep -c "fixed in"` prints 1
- `grep -c "is never logged" .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md` prints 0 (the false claim is rewritten, not quoted)
- The Revert-checks table has three rows, each with a FAIL line and a commit sha that exists (`git -C <repo> cat-file -t <sha>` prints commit)
- `grep -c "TestSessionReaper\|TestAuthTime\|TestMemberTokenScopes\|TestBackendLinkAuthTime\|TestMCPVerifierRevocation\|TestMCPRevocationEndToEnd" .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md` prints at least 6
- `grep -c "B-IN-05\|B-IN-06\|PHASE197_RELEASED" .planning/phases/19.7-generic-mcp-module/deferred-items.md` prints at least 3
</acceptance_criteria>
<done>The security documents state only what tests prove, correct the earlier false verdicts, cover every new threat, and show the critical fixes failing without their code; the validation map and deferred items match the gap closure; the gate is green after the documentation commits.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Gate:** `scripts/check-phase19.7.sh` with:
- `workspace_check`;
- the unreleased-pin policy and `PHASE197_RELEASED` strict mode;
- the `USER_SHORT_TESTS` and `VUE_FILES` arrays;
- the cooler doc check;
- every new required name.
**Framework tests:**
- pier: `TestSessionReaper`, `TestSessionReserve`, `TestLockScope`, `TestScopeWidening`, `TestStatelessFanOut`, `TestPersonalTokenBinding`, `TestDispatchHardening`, `TestCatalogPathFields`, `TestPromptWriteCeiling`, `TestTokenScopedRevoke`, `TestResolveClientAddress`;
- bouncer: `TestAuthTime`;
- pact: `TestFrontendTokenScopes`;
- towel: `TestClientIP`;
- cabana: `TestTokenScopeNameParity`, `TestAccountContributionURL`, `TestAccountContributionsIsolation`, `TestRecordActionPromptLocalized`, `TestAccountTokenRevokeEvent`, `TestResolveBackendTokenRemote`;
- vitest: `admin/tests/app/hotkeysEditing.test.ts`, plus the extended `AccountContributions.test.ts`.
**Plugin, site and demo tests:**
- sm-user-plugin: `TestMemberTokenScopes`, `TestMemberTokenScopesBoot`, `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestOrganisationLogo`;
- sm-oauthserver-plugin: `TestMCPVerifierRevocation`, `TestMCPGrantRevocation`;
- sm-journal-plugin: `TestJournalPromptWrites`;
- sm-demo-app: `TestMCPRevocationEndToEnd`;
- vue-demo-app: `app/lib/linkCode.test.ts`.
**Documents:**
- `19.7-VALIDATION.md` rows;
- `19.7-SECURITY.md` and `19.7-SECURITY-REVIEW.md`: the corrections, T-19.7-42..65, and the revert-checks;
- `deferred-items.md`: deferrals, the release order and the local-only wiring.
**Decisions:** P-40 and P-41.
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| gate script to six repositories | the script decides the phase verdict from test output across repositories and local workspace files |
| security documents to the release decision | v0.5.0 is tagged on the strength of these documents |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.7-64 | Repudiation | a green gate built on a stray local workspace or on unreleased plugin drift | medium | mitigate | `workspace_check` refuses a repository resolving the framework or the user plugin elsewhere; unreleased drift is accepted only when the canonical checkout holds the commits, and is reported; `PHASE197_RELEASED=1` refuses any drift after the release; self-test cases for each outcome |
| T-19.7-65 | Repudiation | a security document marking a control verified without a test that fails without it (the T-19.7-22 failure) | high | mitigate | revert-checks for the three critical findings recorded with FAIL and PASS evidence; the Method step requiring a failing-without-fix test for high and critical controls; the Corrections table for every disproved verdict |
| T-19.7-SC | Tampering | package installs | low | mitigate | no package added; vitest runs use the committed locks (`npm --prefix admin ci` when needed, vue-demo-app's existing node_modules or `pnpm install --frozen-lockfile`) |
</threat_model>
<verification>
- `bash scripts/check-phase19.7.sh --all` on a Docker host: PASS for self-test, go, plugins, consumers, admin, docs, marker and docker
- `bash scripts/check-phase19.6.1.sh --all` still passes (the earlier gate is not broken by the framework changes)
- The framework's `go vet ./... && go test -count=1 ./...` with Docker, and `cd examples/hello && go test ./...`
- `19.7-SECURITY.md` has `threats_open: 0`; `19.7-SECURITY-REVIEW.md` covers T-19.7-01..65 and T-19.7-SC, with the Corrections and Revert-checks sections
</verification>
<success_criteria>
- Unit tests are the last plan, and every gap-closure fix and both escalations have required, named tests (E-2's acceptance verbatim)
- The gate is fail-closed against workspace and pin accidents, and green
- The security review is corrected and extended, with revert-check evidence for the critical findings; the validation map and deferred items are current
- No tag, push or deploy
</success_criteria>
<output>
Create `.planning/phases/19.7-generic-mcp-module/19.7-09-SUMMARY.md` when done, with a commit table per repository, the gate output of the final `--all` run, and the revert-check results. Commit the SUMMARY separately as a planning doc. The SUMMARY also names the g15office follow-up for the orchestrator's phase-advance step (CLAUDE.md rule 4).
Release rule: this plan does not tag, push or deploy. Every repository works against the local framework (go.work, or `replace ../summercms.go`), and every plugin's go.mod `require` line stays at its current version. The plans may commit locally in every repository. After the re-review, phase verification, Nyquist validation and UAT pass (user decision 2026-10-23), the releases happen in this order:
1. framework v0.5.0;
2. the sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags that require it;
3. the application bumps;
4. `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` to confirm the pins.
</output>

View File

@@ -0,0 +1,373 @@
---
phase: 19.7-generic-mcp-module
plan: 09
subsystem: testing
status: complete
gap_closure: true
tags: [pier, bouncer, cabana, pact, towel, admin-spa, vitest, sm-user-plugin, sm-oauthserver-plugin, sm-journal-plugin, sm-demo-app, vue-demo-app, gate, security-review, revert-check, gap-closure]
requires:
- phase: 19.7-06
provides: pier lane A fixes (reaper, reservation, widening, stateless lifecycle, dispatch hardening, prompt ceiling) and their seams
- phase: 19.7-07
provides: auth_time, pact.HasFrontendTokenScopes, narrowed revocation, towel.ClientIP, cabana and SPA lane B fixes
- phase: 19.7-08
provides: plugin lane C fixes, E-1 (organisation logo) and E-2 (member token scopes)
provides:
- a named test for every fix of plans 06-08 and both escalations, E-2's acceptance list item by item
- check-phase19.7.sh with the workspace check, consumers built against the unreleased user plugin, the unreleased-pin policy and PHASE197_RELEASED strict mode, the demo site's vitest run and the cooler doc check
- the corrected and extended 19.7-SECURITY-REVIEW.md (Corrections, Revert-checks, T-19.7-42..T-19.7-65) and 19.7-SECURITY.md (threats_open 0)
- 19.7-VALIDATION.md rows for the gap closure and the updated deferred-items.md (release order)
affects: [phase 19.7 re-review of the gap closure, msd-verifier, /msd-validate-phase 19.7, /msd-verify-work 19.7, framework v0.5.0 and the plugin tags, sm-servers-app (E-1, E-2)]
actuals:
tokens: 61800
tasks: 4
commits: 9
commits_by_repo:
summercms.go: 9
sm-user-plugin: 3
sm-oauthserver-plugin: 1
sm-journal-plugin: 1
vue-demo-app: 1
sm-demo-app: 2
plan_head_before: 05d7e9351a45eb96cf97eaab8fc3de41d81536ae
plan_head_after: e1f489058c0efa68e60886317d4310201f8df384
tech-stack:
added: []
patterns:
- "Revert-check: a critical fix is reverted in a scratch worktree with the tests kept at HEAD; the pinning test must fail there and pass at HEAD"
- "Gate trusts no local workspace: go list -m answers where each repository resolves the framework and the user plugin"
- "A plugin submodule ahead of its pin is accepted only by commits its canonical checkout holds, and reported as unreleased"
- "Consumers are built against an unreleased plugin through a scratch copy of their go.work, never by editing it"
key-files:
created:
- modules/pier/reaper_test.go
- modules/pier/reserve_test.go
- modules/pier/lock_scope_test.go
- modules/pier/widening_test.go
- modules/pier/stateless_fanout_test.go
- modules/pier/personal_binding_test.go
- modules/pier/dispatch_hardening_test.go
- modules/pier/catalog_path_fields_test.go
- modules/pier/prompt_ceiling_test.go
- modules/pier/token_events_test.go
- modules/bouncer/auth_time_test.go
- modules/pact/frontend_token_scopes_test.go
- modules/towel/client_ip_test.go
- modules/cabana/account_contributions_hardening_test.go
- modules/cabana/record_action_prompt_localized_test.go
- modules/cabana/token_scope_name_test.go
- modules/cabana/account_token_events_test.go
- modules/cabana/token_resolver_remote_test.go
- admin/tests/app/hotkeysEditing.test.ts
- ../sm-demo-app/plugins/golem15/user/member_token_scopes_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_auth_time_test.go
- ../sm-demo-app/plugins/golem15/user/backend_link_show_test.go
- ../sm-demo-app/plugins/golem15/user/access_revocation_test.go
- ../sm-demo-app/plugins/golem15/user/organisation_logo_test.go
- ../sm-demo-app/plugins/golem15/user/mcp_guard_client_ip_test.go
- ../sm-demo-app/plugins/golem15/user/api_token_events_test.go
- ../sm-oauthserver-plugin/mcp_revocation_test.go
- ../sm-journal-plugin/mcp_prompt_writes_test.go
- ../sm-demo-app/mcp_revocation_e2e_test.go
- ../sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts
modified:
- scripts/check-phase19.7.sh
- modules/pier/export_test.go
- admin/tests/account/AccountContributions.test.ts
- ../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go
- ../sm-demo-app/plugins/golem15/user/README.md
- ../sm-oauthserver-plugin/README.md
- ../sm-journal-plugin/README.md
- ../sm-demo-app/mcp_e2e_test.go
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY-REVIEW.md
- .planning/phases/19.7-generic-mcp-module/19.7-SECURITY.md
- .planning/phases/19.7-generic-mcp-module/19.7-VALIDATION.md
- .planning/phases/19.7-generic-mcp-module/deferred-items.md
key-decisions:
- "P-40 as built: workspace_check resolves with go list -m ({{.Replace.Dir}} else {{.Dir}}) through realpath, in the plugins, consumers (all three applications) and docker stages; demo_pins accepts unreleased commits only when the canonical checkout holds the submodule HEAD; PHASE197_RELEASED=1 refuses any drift before the policy runs"
- "P-41 as built: the tests stay at HEAD in every revert worktree; where a revert conflicts or removes a seam, later commits' code is kept and the seam answers the no-fix value (ReapNow 0); C-CR-02 reverts only the confirm handler of 32f9259"
- "Organisation attachments are deleted by a new FormAfterDelete hook (not in FormBeforeDelete), so the plugin's exported method declarations stay unchanged for the additive check"
- "TestSessionReaper requires the reaper for the copy of the instance that did not serve the last request; the other copy may also be closed by a late persist that finds the record gone"
patterns-established:
- "A Postgres-backed test returns before creating subtests under -short (no t.Skip), so the gate's skip refusal stays meaningful"
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-05, D-08, D-17, D-19, D-20, D-21, D-25, D-26, D-27, D-31, E-1, E-2]
coverage:
- id: D1
description: "Named framework tests for every pier fix of plan 06 (reaper, reservation, lock scope, widening, stateless fan-out, personal binding, dispatch hardening, path fields, prompt ceiling)"
requirement: "D-25"
verification:
- kind: unit
ref: "modules/pier/*_test.go#TestSessionReaper TestSessionReserve TestLockScope TestScopeWidening TestStatelessFanOut TestPersonalTokenBinding TestDispatchHardening TestCatalogPathFields TestPromptWriteCeiling"
status: pass
- kind: other
ref: "bash scripts/check-phase19.7.sh --go"
status: pass
human_judgment: false
- id: D2
description: "Named tests for every framework and admin SPA fix of plan 07 (auth_time, token-scoped revocation, client address, scope name rule, account action URL and section isolation, localized prompt refusal, hotkeys while editing)"
requirement: "D-05"
verification:
- kind: unit
ref: "modules/bouncer/auth_time_test.go#TestAuthTime; modules/pier/token_events_test.go#TestTokenScopedRevoke TestResolveClientAddress; modules/cabana (TestAccountContributionURL, TestAccountContributionsIsolation, TestRecordActionPromptLocalized, TestTokenScopeNameParity, TestAccountTokenRevokeEvent, TestResolveBackendTokenRemote); modules/pact#TestFrontendTokenScopes; modules/towel#TestClientIP"
status: pass
- kind: automated_ui
ref: "admin/tests/app/hotkeysEditing.test.ts; admin/tests/account/AccountContributions.test.ts"
status: pass
human_judgment: false
- id: D3
description: "E-2 acceptance list as tests, item by item, plus the boot refusals"
requirement: "E-2"
verification:
- kind: integration
ref: "sm-user-plugin/member_token_scopes_test.go#TestMemberTokenScopes TestMemberTokenScopesBoot"
status: pass
human_judgment: false
- id: D4
description: "Plugin, site and demo tests for plan 08 (link proof, link code transport, access revocation, client IP, token revoke event, organisation logo, verifier and grant revocation, Journal prompt ceiling, demo revocation matrix, link code reader)"
requirement: "SC-4"
verification:
- kind: integration
ref: "sm-user-plugin (TestBackendLinkAuthTime, TestBackendLinkShowPost, TestAccessRevocation, TestUserMCPGuardClientIP, TestAPITokenRevokeEvent, TestOrganisationLogo); sm-oauthserver-plugin (TestMCPVerifierRevocation, TestMCPGrantRevocation); sm-journal-plugin#TestJournalPromptWrites"
status: pass
- kind: e2e
ref: "sm-demo-app/mcp_revocation_e2e_test.go#TestMCPRevocationEndToEnd"
status: pass
- kind: unit
ref: "vue-demo-app/app/lib/linkCode.test.ts"
status: pass
human_judgment: false
- id: D5
description: "Extended fail-closed gate (workspace check, consumers on the unreleased user plugin, unreleased-pin policy, PHASE197_RELEASED, vue vitest, cooler docs) green with every stage PASS"
requirement: "D-31"
verification:
- kind: other
ref: "bash scripts/check-phase19.7.sh --all (Phase 19.7 gate passed); PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers exits 1"
status: pass
human_judgment: false
- id: D6
description: "Corrected and extended security review and contract (Corrections, revert-checks for the three critical fixes, T-19.7-42..65), validation rows and deferred items"
verification:
- kind: other
ref: "Task 4 verify chain (threats_open 0, every T-19.7-42..65 in both documents, Revert-check section, B-IN-05, TestMemberTokenScopes) and the revert-check runs"
status: pass
human_judgment: true
rationale: "The corrections and threat verdicts are a reviewer's judgment; the re-review of the gap closure and msd-verifier must confirm them"
duration: 88min
completed: 2026-10-23
---
# Phase 19.7 Plan 09: Gap-closure tests, the extended gate and the corrected security review Summary
Every fix of the gap-closure plans 06-08 and both sm-servers-app escalations is now pinned by a named test that the phase gate requires: 21 framework and SPA tests, 8 user-plugin tests (E-2's acceptance list item by item), 2 OAuth server tests, a Journal test, a demo end-to-end revocation matrix and a vue-demo-app unit test. The gate no longer trusts local workspace files: it checks where each repository resolves the framework and the user plugin, builds fonoteka.go and sm-bm-app against the unreleased user plugin through scratch go.work copies, reports the demo's plugin submodules as "ahead of its pin by unreleased commits", and refuses any drift under `PHASE197_RELEASED=1`. The security review now corrects the eight verdicts the code review disproved (T-19.7-22 first) and records T-19.7-42..T-19.7-65. The revert-checks show that each of the three critical fixes' tests fails with the fix reverted.
## Performance
- **Duration:** about 88 min
- **Started:** 2026-10-23T15:32:53Z
- **Completed:** 2026-10-23T17:01:31Z
- **Tasks:** 4 of 4
- **Files:** 43 across six repositories (30 created)
## Commits
Nothing was tagged, pushed or deployed.
**summercms.go** (master, plan base 05d7e93; 9 commits, measured)
| Commit | Task | What |
|--------|------|------|
| 0aa48ae | 1 (tracer) | gate: workspace check, scratch go.work consumers, unreleased-pin policy, `PHASE197_RELEASED`, self-test cases; `TestSessionReaper`, `TestAuthTime` |
| fda4646 | 1 (follow-up) | `TestSessionReaper` tolerates a transient store read on a loaded host |
| ccf77b9 | 2 | the plan 06/07 framework, cabana and SPA tests; the `WrapStore` seam; gate arrays |
| c2d486b | 2 (follow-up) | `TestSessionReaper` requires the reaper only where nothing else can close the copy |
| 7f43e0e | 3 | gate: `USER_SHORT_TESTS`, plugin and demo names, `VUE_FILES` vitest run, cooler doc check |
| 946fb2d | 4 | docs: 19.7-SECURITY-REVIEW.md corrected and extended |
| 33faa44 | 4 | docs: 19.7-SECURITY.md register, corrected rows, audit trail |
| a636933 | 4 | docs: 19.7-VALIDATION.md rows |
| e1f4890 | 4 | docs: deferred-items.md |
**sm-user-plugin** (`../sm-demo-app/plugins/golem15/user`, master, base f20a8d3)
| Commit | Task | What |
|--------|------|------|
| 25b9f2e | 3 | `fix:` deleting an organisation removes its avatar and logo attachments (production defect found by `TestOrganisationLogo`) |
| e3c164f | 3 | tests: `TestMemberTokenScopes`, `TestMemberTokenScopesBoot`, `TestBackendLinkAuthTime`, `TestBackendLinkShowPost`, `TestAccessRevocation`, `TestUserMCPGuardClientIP`, `TestAPITokenRevokeEvent`, `TestOrganisationLogo`; README Testing |
| 38551a4 | 3 | `fix:` the attachment cleanup runs in a new `FormAfterDelete` (keeps the plugin additive) |
**sm-oauthserver-plugin** (master, base 671513d): 65f71dc `test:` `TestMCPVerifierRevocation`, `TestMCPGrantRevocation` (README Testing).
**sm-journal-plugin** (main, base b129902): 2aeeccc `test:` `TestJournalPromptWrites` (README Testing).
**vue-demo-app** (master, base 0125e29): b973a76 `test:` `app/lib/linkCode.test.ts`.
**sm-demo-app** (master, base 7731641): faeece2 `test:` `TestMCPRevocationEndToEnd`, `bootMCPDemoWith`, unused `connectWatching` removed; f53d30a `chore:` vue-demo-app pointer bump (staged alone).
## Accomplishments
- **Task 1 (tracer).**
- Gate (P-40):
- `workspace_check` runs at the top of the plugins, consumers and docker stages and refuses a mismatch with both paths named.
- `consumer_with_user_plugin` builds fonoteka.go and sm-bm-app against `$USER_PLUGIN`. Both applications' go.work files are untouched (status count 0).
- `demo_pins` gained the unreleased-commit policy and released mode.
- The header documents `PHASE197_RELEASED`.
- 31 self-test cases print one PASS line each.
- `TestSessionReaper` and `TestAuthTime` are required in the go stage, together with the plan 06 and 07 smokes.
- Tracer gate: the run is interactive and `end-of-phase`, and the verify chain is automated only. Re-running self-test, go, plugins and consumers end to end passed before the expansion tasks started. `PHASE197_RELEASED=1 --consumers` exits 1 while the pins drift.
- **Task 2.**
- Every behaviour of the plan's behavior block is a test under its exact name, with the cases as subtests.
- Every test passed at once, so no framework fix was needed. Because none could start red, the RED evidence for each was checked by hand where a fix could be undone cheaply. With `originOf` and the blur-before-click reverted in hotkeys.ts, 3 of the 4 hotkeysEditing cases fail. With the reaper reverted, `TestSessionReaper` fails (see the revert-checks).
- `go test -race ./modules/pier` is clean over 3 to 8 repeated runs.
- **Task 3.**
- The plugin, site and demo tests are written under the plan's names, one commit per repository, with the fix commits first.
- The gate requires all of them, and the demo site's vitest run requires `app/lib/linkCode.test.ts`.
- The docs stage checks the two cooler doc comments.
- **Task 4.**
- The security review gained:
- Method step 6;
- the Corrections table: T-19.7-22, cross-cutting findings 1 and 6, T-19.7-23, -03, -17, -18, -08, -31 and -35;
- cross-cutting findings 1 and 6 rewritten in place;
- the Revert-checks table;
- register rows T-19.7-42..T-19.7-65 plus the gap-closure T-19.7-SC.
- 19.7-SECURITY.md has 66 register rows, `threats_open: 0` and an audit-trail entry.
- Validation and deferred items are updated.
## Revert-checks (P-41)
Each worktree was created under the session scratch directory and removed afterwards with `git worktree remove --force` and `git worktree prune`. Nothing was committed from any of them.
| Repo | Fix reverted | Test | With the fix reverted | At HEAD |
|------|--------------|------|-----------------------|---------|
| summercms.go | 2bf9dca (A-CR-01) | `TestSessionReaper` | FAIL `reaper_test.go:79: reaped = 0, want 1` (all 5 subtests fail) | PASS |
| sm-oauthserver-plugin | 671513d (C-CR-01) | `TestMCPVerifierRevocation` | FAIL `mcp_revocation_test.go:98: created two seconds before the cutoff: verify = <nil>, want pact.ErrMCPTokenInvalid` (all 5 subtests fail) | PASS |
| sm-user-plugin | 32f9259 (C-CR-02, the confirm handler) | `TestBackendLinkAuthTime` | FAIL `backend_link_auth_time_test.go:64: confirm = 200 …, want 409 with "stale_session"` (and `:86`) | PASS |
## Gate output (final `bash scripts/check-phase19.7.sh --all`, after the documentation commits)
```
PASS self-test
PASS go
PASS plugins
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed
```
The consumers stage printed:
- `pins: plugins/golem15/journal is ahead of its pin by unreleased commits (release after verification)`, and the same line for `oauthserver` and `user`;
- the vue-demo-app vitest summary: 182 tests passed.
The admin vitest run: 1552 tests passed.
The docker stage ran the framework's cabana suites, then 16 user-plugin, 7 OAuth server and 2 demo top-level tests, all by name.
Also run:
- the framework's `go vet ./...`, `go vet -tags summercms_mcp ./...` and `go test -count=1 ./...` with Docker, plus examples/hello: green;
- `bash scripts/check-phase19.6.1.sh --all`: "Phase 19.6.1 gate passed";
- `GOWORK=off go test -count=1 ./...` in the user plugin with Docker: green.
go.mod, go.sum and the npm locks are unchanged in every repository.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Deleting an organisation left its attachments behind (E-1)**
- **Found during:** Task 3 (`TestOrganisationLogo`, "deleting the organisation removes the logo attachment as it does the avatar")
- **Issue:** The plan assumed the avatar is removed with the organisation. Neither the avatar nor the new logo was removed: the `system_files` rows and their blobs outlived the organisation row.
- **Fix:**
- 25b9f2e deletes the attachments in the delete's transaction (`attach.DeleteForOwner`) and the blobs after the commit, as the user delete does. The README's Organisations line says so.
- The first version changed `FormBeforeDelete`'s receiver, which the gate's additive check reads as a changed exported method declaration. 38551a4 therefore moves the cleanup into a new `FormAfterDelete` and restores `FormBeforeDelete` exactly.
- **Files:** `../sm-demo-app/plugins/golem15/user/controllers/organisations_admin_controller.go`, `README.md`
- **Commits:** 25b9f2e, 38551a4 (sm-user-plugin)
**2. [Rule 1 - Test race] `TestSessionReaper`'s Redis case was flaky under load**
- **Found during:** Task 2's verify (one run in roughly ten of the full suite)
- **Issue:** A request's state is persisted after its answer has been streamed. When the instance that served the last request was still persisting after the clock moved past the expiry, persist found the record gone and closed the copy itself. The reaper then counted 0 for that instance.
- **Fix:**
- fda4646 reaps until the copies are gone and asserts both copies before the expiry.
- c2d486b requires the reaper for the copy of the instance that did not serve the last request and allows the other copy to be closed by the late persist. That behaviour is correct (an ended session is never written back).
- After both commits: 30 consecutive passes, and a clean full gate.
- **Commits:** fda4646, c2d486b
**3. [Rule 1 - Test race] `TestSessionReserve` checked the local copies before the release had run**
- **Found during:** a background stress run of Task 2
- **Fix:** The test waits for each failed initialize's slot release before the next attempt. It was folded into ccf77b9 before that commit.
**4. [Plan detail] Placement and harness choices**
- `TestResolveClientAddress` is in `token_events_test.go`. The plan's acceptance grep counts it among the nine pier files, and the plan lists no file of its own for it.
- The `-short` part of `TestResolveBackendTokenRemote` (a failed resolve without cabana logs `remote`) is also required in `GO_TESTS`.
- `TestDispatchHardening` drives its contradicting and failing dispatches through one frontend guard keyed by the route method, because pier allows one frontend guard per application.
- `TestJournalPromptWrites` serves pier with a stand-in backend guard. The built-in backend guard needs the admin database, and the test runs under `-short`.
- `consumer_with_user_plugin` uses its own go.work rewriter rather than plan 08's fixed template.
- The workspace check also covers fonoteka.go and sm-bm-app in the consumers stage, because the plan says every repository must resolve the framework to this checkout.
- The gate's `--self-test` now prints one PASS line per planted case (31 cases).
**5. [Plan wording] `readLinkCode` with extra fragment parameters**
- The plan says "extra parameters … give ''". The implementation parses the fragment with URLSearchParams, so `#code=<hex>&next=/x` answers the code.
- The test pins extended and foreign values (''), and leaves the lenient case unasserted.
- It is recorded in deferred-items.md for the re-review to decide. No production change was made in this tests-only plan.
**6. [Revert-check method] C-CR-02 reverted at the handler level**
- A whole-commit revert of 32f9259 also removes the `#code=` URL, so the test would fail at the link URL regexp before reaching the auth_time assertions.
- Only `BackendLinkConfirm` was restored to its pre-fix `iat` body, which isolates the property.
- A-CR-01's revert conflicted with later commits. They were resolved keeping the later code, and the `ReapNow` seam answers 0.
**Total deviations:** one production defect fixed in its own commits (Rule 1), two test races in this plan's own tests (Rule 1), and plan-detail choices recorded above. **Impact:** no shipped framework code changed. The user plugin gained a `FormAfterDelete` hook, and that change is additive.
## Issues Encountered
- One `go test -short ./...` run failed in `docs/examples/blog` with "module lookup disabled by GOPROXY=off". It has not recurred in more than twenty runs. It is recorded in deferred-items.md.
- The repositories have `git rerere` enabled, so the revert worktrees recorded conflict preimages. `git rerere clear` ran in each worktree before it was removed.
## TDD Gate Compliance
Tasks 2 and 3 carry `tdd="true"`, but they pin behaviour that plans 06-08 had already built ("most pass at once"). There is therefore no `feat(19.7-09)` GREEN commit and no failing RED commit for those tests.
Red evidence instead:
- the three revert-checks;
- the hotkeys revert run of Task 2;
- `TestOrganisationLogo`, which failed for a real defect and went green with fix commits 25b9f2e and 38551a4.
## Known Stubs
None.
## Threat Flags
None. The plan adds tests, a gate and documents. Its only production change, the organisation attachment cleanup, is part of T-19.7-61.
## User Setup Required
None.
## Next Phase Readiness
- Phase 19.7's gap closure is complete. The phase is ready for the fresh re-review of plans 06-09, then the regression gate, msd-verifier, /msd-validate-phase 19.7 and /msd-verify-work 19.7. UAT includes a real MCP client against the local demo.
- Release order and local-only wiring: see deferred-items.md.
- Do not commit sm-oauthserver-plugin's go.work retarget. sm-demo-app's `main.go` and its plugin submodule pointers stay unstaged.
- The release order is: framework v0.5.0, then the plugin tags, then the application bumps (vue-demo-app pushed first), then `PHASE197_RELEASED=1 --consumers`.
- **g15office follow-up for the orchestrator** (CLAUDE.md rule 4): at the phase-advance step, move tasks hmguritm and tqyvnzxx into milestone dcnv4mha and post the short phase comment, "gap closure 06-09 complete, gate green, awaiting re-review and verification".
## Self-Check: PASSED
- FOUND: all 30 created test files across the six repositories, and the modified gate and documents.
- FOUND: the framework commits 0aa48ae, fda4646, ccf77b9, c2d486b, 7f43e0e, 946fb2d, 33faa44, a636933, e1f4890.
- FOUND: the user plugin commits 25b9f2e, e3c164f, 38551a4; oauthserver 65f71dc; journal 2aeeccc; vue-demo-app b973a76; sm-demo-app faeece2, f53d30a.
- Revert-check fix shas 2bf9dca, 671513d and 32f9259 resolve to commits (`git cat-file -t`).
- No commit deleted a tracked file.
---
*Phase: 19.7-generic-mcp-module*
*Completed: 2026-10-23*

View File

@@ -0,0 +1,73 @@
# Phase 19.7 gap-closure inputs
Recorded 2026-10-23 from the user's answer at the code-review gate. The gap-closure plans cover three sources:
1. **Code review** (`19.7-REVIEW.md`): fix every critical and warning finding (3 critical, 17 warnings) before v0.5.0. Info findings are fixed where cheap; the rest go to `deferred-items.md` with a reason.
2. **Escalation E-1** (sm-servers-app): organisation logo.
3. **Escalation E-2** (sm-servers-app): plugin-declared scopes for member (`usr_`) API tokens.
sm-user-plugin is a core plugin used by many applications: every change is additive (no removed export, route, column or payload field; no changed response shape).
v0.5.0 is tagged only after the gap closure, a fresh code review of the changes, phase verification, Nyquist validation and UAT are all green (user decision 2026-10-23).
## E-1: Organisation logo (sm-user-plugin)
Organisations get a `logo` file field (an image attachment through `lagoon/attach`), editable in the backend Organisations form, so an application can upload an organisation's logo and show it on the frontend. The frontend organisation payloads gain the logo (URL or attachment object, following how the plugin already exposes user avatars), as an added field only.
## E-2: Plugin-declared scopes for member (`usr_`) API tokens
Escalated by sm-servers-app (PxPx Servers Inventory). Affects sm-user-plugin v0.1.9+ and `pact.HasTokenScopes`. Priority: blocking for their Phase 02.1 (agent component reporting).
### Problem
Member personal API tokens (`usr_`) can only carry the fixed scopes `read`, `write` and `ai`. A plugin cannot declare its own scopes for them.
- `sm-user-plugin/classes/api_tokens.go`: `AllowedAPITokenScopes = []string{"read", "write", "ai"}`. `MintAPIToken` refuses anything else.
- `sm-user-plugin/controllers/api_tokens.go`, `apiTokenScopes`: `POST /_user/api/v1/tokens` answers 422 "The selected scope is invalid." for any other value.
- `pact.HasTokenScopes` (`TokenScopes() []pact.TokenScope`) exists, but only `cabana/token_scopes.go` reads it, for the backend `g15_` token catalog. The user plugin ignores it.
A plugin that wants a narrow agent token (for sm-servers-app: `servers:read` and `servers:write`) has to fall back to the generic `write`. Every app sharing the token table then gives the same scope a different meaning, and a token's scopes no longer say what it is for.
### Use case
Organisation members mint a personal token from the app's Nuxt screen and hand it to a recon agent. The agent calls:
- `GET /_servers/api/v1/servers?host=&port=` with scope `servers:read`
- `PUT /_servers/api/v1/servers/{id}/components/report` with scope `servers:write`
The routes are guarded by `APITokenGuard` plus `middleware.Scope("servers:write")`. The app also checks the member's live role, so no mint-time role check is asked for.
### Ask
1. **Declaring:** the user plugin builds its allowed scope list from the built-in `read`, `write` and `ai` plus every active plugin's `pact.HasTokenScopes`. It reuses `pact.TokenScope`, or a user-plugin twin if the backend and frontend catalogs should stay apart. It validates names the way cabana does (pattern, no duplicates, no clash with the built-ins) and fails boot on a bad one.
2. **Minting:** `POST /_user/api/v1/tokens` and `MintAPIToken` accept the declared scopes.
3. **Checking:** `middleware.Scope` and `APIToken.HasScope` work with the declared scopes, with no special casing.
4. **Listing:** `GET /_user/api/v1/tokens` returns them in `scopes`, as it does today.
5. **Discovery:** an endpoint (for example `GET /_user/api/v1/tokens/scopes`, behind `jwt.auth`) or an exported helper that returns the allowed scopes with name, translated label and description, so a frontend can render them without hard-coding.
### Not asked for
- A mint-time hook to refuse a scope based on the user's role.
- Any change to backend `g15_` tokens or the cabana catalog.
- Any change to `tokens_valid_after` handling (G8 stays as discussed).
### Acceptance
- A plugin that declares `servers:write` can mint a `usr_` token with that scope through `POST /tokens`, and a route with `middleware.Scope("servers:write")` accepts it.
- A token without the scope gets 403 "Missing required scope: servers:write".
- An undeclared scope still answers 422 at mint time.
- Two plugins declaring the same scope name, or a plugin redeclaring `read`, `write` or `ai`, fails boot.
- The discovery answer lists the built-ins and the declared scopes with labels.
### Open design point for the planner
`pact.HasTokenScopes` today feeds the backend `g15_` catalog in cabana. If the user plugin also reads it, a scope a plugin declares for member tokens would also appear in the admin token catalog, and the reverse. Decide between reusing the interface (one catalog, both token kinds) and a separate frontend interface. The escalation does not ask for any change to the backend catalog, so a separate interface (or an audience field) is likely the safer choice. Record the choice as a decision.
## Planning decisions (user, 2026-10-23)
- **Four gap-closure plans:**
- 19.7-06, pier hardening (lane A), wave 1
- 19.7-07, framework auth (`auth_time`), cabana and admin SPA (lane B), plus the E-2 framework seam, wave 1
- 19.7-08, the plugins (sm-user-plugin with C-CR-01, C-CR-02, C-WR-01, E-1 and E-2; oauthserver with C-CR-01 and C-WR-02; Journal with C-IN-01) and the demo, wave 2
- 19.7-09, unit tests, the extended gate and the security-review corrections, wave 3 (the last plan, by project rule)
- **Release order:** no tag in plans 06-09. Everything is developed and tested against the local framework (`replace ../summercms.go` / go.work). After the re-review, verification, Nyquist validation and UAT pass: tag framework v0.5.0, then tag sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin requiring v0.5.0, then bump the applications. sm-servers-app waits for that release for E-2.

View File

@@ -0,0 +1,377 @@
---
phase: 19.7-generic-mcp-module
review: 19.7-REREVIEW.md
fixed: 2026-10-23
iteration: 1
findings_in_scope: 28
status: all_fixed_or_deferred
summary: "1 critical and 10 warnings fixed, each with a test that fails on the old code; info items fixed where cheap, the rest deferred with a reason in deferred-items.md"
---
# Phase 19.7 re-review fix report
The user chose the code-review fixer route (2026-10-23) with cheap info findings included. Three fixer lanes ran in sequence on the main checkouts: A (framework MCP core), B (cabana and admin SPA), C (plugins and demo, including the framework seam for RC-WR-01). Every deferral is also recorded in `deferred-items.md`.
Final gate (`bash scripts/check-phase19.7.sh --all`, after lane C): every stage PASS, verdict "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (plugins/golem15/journal plugins/golem15/oauthserver plugins/golem15/user)". The qualified verdict is expected until the release.
Items flagged for human verification at UAT:
- RB-WR-01: the save shortcuts inside a plugin field type's shadow root are verified in happy-dom only; check in Firefox.
- RC-CR-01 and RC-WR-01: the password-change revocation and the consented-administrator binding change revocation logic and add per-user advisory locks; worth a human look.
- RC-WR-01 behaviour change: backend grants issued before oauthserver migration 202610230001 lose backend reach until the agent reconnects.
# Lane A
## Phase 19.7: Code Review Fix Report (re-review, lane A)
**Fixed at:** 2026-10-23
**Source review:** .planning/phases/19.7-generic-mcp-module/19.7-REREVIEW.md (section "# Lane A")
**Iteration:** 1
**Summary:**
- Findings in scope: 12 (6 warnings, 6 info)
- Fixed: 12. RA-IN-04 is only partly fixed; its leftover part is deferred.
- Skipped: 0
- Deferred residuals (recorded in deferred-items.md, commit eea04b1): the check-then-act cap in RA-IN-04, and the optional upstream check in RA-WR-06
**Where verification ran:** in the main checkout. `workflow.use_worktrees` is false, so no worktree was created. Every commit was checked with `go vet ./...`, `go vet -tags summercms_mcp ./...` and `go test -short ./...`, all green. `go test ./modules/pier/...` (with `-race` for the concurrency changes) and `go test ./cmd/summer -run TestDocsTree` were run after each pier or doc change. `scripts/check-phase19.7.sh --self-test` passes. I also ran `--demo-pins` and `--consumers` against the real sibling checkouts. They exit 0 and print the qualified line.
Each warning fix has a test that I first ran against the reverted code. Every one failed there, and passes with the fix.
## Fixed Issues
### RA-WR-01: Event fan-out nests session locks under the router lock; guard I/O under those locks has no deadline
**Files modified:** `modules/pier/events.go`, `modules/pier/session.go`, `modules/pier/stateless.go`, `modules/pier/config.go`, `modules/pier/README.md`, `docs/services/mcp.md`, `modules/pier/lock_scope_test.go`
**Commit:** cccf402
**Applied fix:**
- **Matching.** `matchingFunc` on both paths now copies the entries while it holds `sr.mu`/`sp.mu`, releases the lock, and then reads each identity. The identity sits in a new `identityBox`, which has its own mutex and is never held across I/O. Matching never takes `ls.mu`/`e.mu`.
- **Deadlines.** `widen`, `recomputeSession` and `recomputeStateless` run their guard checks under `guardContext`, which is `context.WithTimeout(context.WithoutCancel(ctx), cfg.CallTimeout)`. The README and the docs now say that `mcp.call_timeout` also bounds these re-filter checks.
**Test:** `TestLockScopeUnderWidening`
- "an event for the blocked session does not stall another session": failed before the fix with "another session's call waited for the blocked widening".
- "a blocked widening ends at mcp.call_timeout": failed before the fix, because the guard check outlived `call_timeout`.
### RA-WR-02: The fingerprint includes the OAuth token id, so a shared stateless server flips its tool set
**Files modified:** `modules/pier/identity.go`, `modules/pier/stateless.go`, `modules/pier/events.go`, `modules/pier/README.md`, `modules/pier/widening_test.go`, `modules/pier/fingerprint_test.go` (new)
**Commit:** f604e6d
**Applied fix:**
- **Fingerprint.** It no longer includes subject token ids, so an access-token rotation alone re-filters nothing.
- **Stateless refresh.** It now only adds tools and prompts. It runs when a fingerprint is new to the entry, and the entry remembers it in `widened`. A fan-out recompute clears that list and can still narrow. The receiving middleware still filters every list and call.
- **Stateful path.** It keeps its exact add-and-remove `widen`, because a session serves one client.
- **LRU key.** I did not re-key the LRU by binding plus fingerprint. The add-only refresh fixes the race without it.
**Tests:**
- `TestFingerprintIgnoresTokenID` (internal).
- `TestStatelessSharedBinding`: a wide token's call is blocked after its server was chosen, then a narrow token sends a request. Before the fix the call failed with `unknown tool "acme_echo"`.
### RA-WR-03: A transient guard error during widening permanently narrows a live session
**Files modified:** `modules/pier/filter.go`, `modules/pier/events.go`, `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/widening_test.go`
**Commit:** e7630e6
**Applied fix:**
- **New check.** `catalog.allowedReport` returns the allowed lists plus the first provider error. `allowed` is now built on it.
- **Widening, both paths.** On an error it logs "pier: widening deferred" and leaves the server and its recorded fingerprint untouched, so the next request retries.
- **Fan-out recompute.** It still applies its diff, which fails closed and is safe for an event. On an error it records no fingerprint, so the binding's next request re-filters.
**Test:** `TestWideningGuardError`, with subtests "widening on stateless", "widening on stateful" and "recompute on an event". All three failed before the fix and listed `[acme_show]` or `[]` after the provider recovered.
### RA-WR-04: LRU capacity eviction ends live listen streams and in-flight calls
**Files modified:** `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/stateless_fanout_test.go`
**Commit:** 928731d
**Applied fix:**
- **Counting active requests.** Each stateless request carries an `entryUse` on its context. `getServer`/`hit` count the entry they hand out as active while holding `sp.mu`. `ServeHTTP` releases it when the request ends, so a listen stream keeps its entry active for its whole life.
- **Eviction.** It walks the LRU from the back and drops only idle entries. If every entry is busy, the LRU stays above capacity until requests end (documented).
- **Revocation.** `evictFunc` is unchanged, so a revocation still closes every stream.
**Test:** `TestStatelessFanOut/an LRU eviction spares a server with a listen stream`. This rewrites the subtest that pinned the old behaviour. When the binding past capacity arrives, the listener's server stays and the oldest idle one is evicted. A revocation then still ends the stream. Before the fix: "the LRU eviction ended a live listen stream".
### RA-WR-05: Prompt Render errors reach the MCP client verbatim
**Files modified:** `modules/pier/session.go`, `modules/pier/README.md`, `docs/services/mcp.md`, `modules/pier/prompt_ceiling_test.go`
**Commit:** 788d4a4
**Applied fix:** `promptHandler` logs the error with the prompt name ("pier: prompt render failed"). It then returns `&jsonrpc.Error{Code: CodeInternalError, Message: "pier: the prompt could not be rendered"}`.
**Test:** `TestPromptRenderError` (stateful and stateless). Before the fix the client received `failed to connect to host=db.internal ...`.
### RA-WR-06: The gate passes while demo submodules carry unreleased non-test commits
**Files modified:** `scripts/check-phase19.7.sh`
**Commit:** 2fdda99
**Applied fix:**
- **Recording.** `demo_pins` records each submodule it accepts as ahead of its pin by unreleased commits. It keeps them in `UNRELEASED_PINS` and, when `PHASE197_PINS_FILE` is set, also writes them to that file, which `run_all` exports.
- **`--consumers`.** With unreleased pins it now ends with "phase19.7 consumers passed WITH UNRELEASED PINS: <names> (the release pins are not yet proven)".
- **`--all`.** It ends through `gate_verdict`. With unreleased pins that prints a notice box and the last line "Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (<names>)". With none it prints the unqualified "Phase 19.7 gate passed".
- **Header comment.** Updated to describe this.
- **Exit status.** It stays 0, and fail-closed by default (`PHASE197_ALLOW_UNRELEASED`) was not added. The release order verifies the phase before anything is tagged. A non-zero exit would fail every pre-release run, and the user's rule only asked for a qualified verdict. A check that greps for the exact line "Phase 19.7 gate passed" now fails while pins are unreleased.
**Test:** new self-test cases:
- `pins-test-only-unrecorded`
- `pins-unreleased-recorded`
- `verdict (unqualified only without unreleased pins)`
Against the real checkouts, journal, oauthserver and user are reported, and the consumers stage prints the qualified line.
### RA-IN-01: lazyStore's doc comment says nothing dials at mount
**Files modified:** `modules/pier/store_redis.go`, `modules/pier/pier.go`
**Commit:** 38e028f
**Applied fix:**
- The `lazyStore` and `newStore` comments now describe the mount-time background warm-up and its logged retries.
- Decision: keep the warm-up for commands that mount pier without serving, such as `route:list`. It only dials and logs until the process exits. This decision is recorded in the comment.
- The README row that said the store opens "on the first request" is corrected in 58408aa.
### RA-IN-02: Stream re-checks resolve the bearer without the client address
**Files modified:** `modules/pier/session.go`, `modules/pier/stateless.go`, `modules/pier/README.md`, `modules/pier/stateless_fanout_test.go`
**Commit:** afc7510
**Applied fix:** The GET stream and the listen stream capture `p.clientIP(r)` when they open. They resolve every re-check with `towel.WithClientIP`.
**Test:** `TestStreamRecheckClientIP`, which failed before the fix: the re-check verifications saw an empty address.
### RA-IN-03: reserveScript touches keys it does not declare
**Files modified:** `modules/pier/store_redis.go`, `modules/pier/README.md`, `docs/services/mcp.md`
**Commit:** 58408aa
**Applied fix:** I took the documentation option. The store needs a standalone Redis. Redis Cluster is not supported, and a key-pattern ACL must allow the whole `<prefix>mcp:` range. This is stated in the script comment, the README `mcp.sessions.store` row and the docs config table.
### RA-IN-04: The non-reserver fallback is check-then-act and briefly exposes an unrecorded session
**Files modified:** `modules/pier/session.go`
**Commit:** 034aa19
**Applied fix:** This is partial. The fallback now calls `Put` right after the Count check and before the server is built. A failed connect or initialize deletes the record again. The session is never in `sr.local` without its record, so a reaper pass can no longer close it as expired. The existing `TestSessionReserve` fallback subtest passes.
**Deferred residual:** the Count-then-Put cap is still racy. Closing it needs `Reserve` in the exported `pier.Store` interface, or a refusal at mount, which changes pier's exported API for a branch only a test wrapper reaches. Recorded in deferred-items.md (eea04b1).
### RA-IN-05: A backend token whose scopes no longer resolve gets "account unknown or deactivated"
**Files modified:** `modules/pier/backend_guard.go`, `modules/pier/dispatch.go`, `modules/pier/README.md`
**Commit:** c0ce07e
**Applied fix:**
- **Sentinel.** The backend guard's `principal` now answers `errScopeUnavailable` when the token's stored scopes resolve to nothing. Before, it answered a nil principal.
- **Dispatch.** It maps this to the fixed tool error "pier: the token's scopes no longer grant this route".
- **Listing.** `MCPAllowsMethod` still treats it as a refusal, so the RA-WR-03 retry logic does not see it as a failed check.
No unit test: the path needs cabana's database-backed `BackendPrincipal`.
### RA-IN-06: demo_pins passes when check-pins.sh exits non-zero with no output
**Files modified:** `scripts/check-phase19.7.sh`
**Commit:** 99f5ed4
**Applied fix:** `demo_pins` counts the mismatch lines it accepts. It refuses when `check-pins.sh` fails without one.
**Test:** new self-test `pins-silent-failure`, which plants a silent `exit 1` `check-pins.sh`.
## Deferred
- **RA-IN-04 residual:** the check-then-act cap of the non-reserving fallback. Fixing it is an exported `pier.Store` API change for a branch only tests reach.
- **RA-WR-06 optional `git branch -r --contains` upstream check:** the gap-closure commits are deliberately unpushed until the release order runs, so this check would refuse every pre-release run. `PHASE197_RELEASED=1` after the tags is the release proof.
Both are appended to `.planning/phases/19.7-generic-mcp-module/deferred-items.md` in the docs commit eea04b1.
---
_Fixed: 2026-10-23_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_
# Lane B
## Phase 19.7: Code Review Fix Report (re-review, Lane B)
**Fixed at:** 2026-10-23T18:39:41Z
**Source review:** .planning/phases/19.7-generic-mcp-module/19.7-REREVIEW.md (section "# Lane B" only)
**Iteration:** 1
**Summary:**
- Findings in scope: 6 (RB-WR-01, RB-WR-02, RB-IN-01..04)
- Fixed: 5 (RB-IN-03 counted as skipped: the code fix stays deferred; only its documentation half landed)
- Skipped/deferred: 1 (RB-IN-03 code fix)
**Where verification ran:** in the main checkout (`workflow.use_worktrees: false`, so no worktree was created). Before every commit: `go vet ./...` and `go test -short ./...`. For admin changes also `npm --prefix admin run test` (101 files) and `npm --prefix admin run typecheck`. `scripts/check-admin-openapi.sh` regenerated the OpenAPI files for RB-IN-02, and `--check` exits 0. `go test ./cmd/summer -run TestDocsTree` passed after each doc change. `scripts/check-admin-dist.sh` matches after the dist commit 34dce1c.
## Fixed Issues
### RB-WR-01: The draft-commit blur does not reach an input inside a plugin field type's shadow root
**Files modified:** `admin/src/app/hotkeys.ts`, `admin/tests/app/hotkeysEditing.test.ts`
**Commit:** 3afe9fa (dist: 34dce1c)
**Applied fix:** Before clicking, the save shortcut now focuses the target button with `preventScroll`, as a pointer press does. This unfocuses an inner input in an open or a closed shadow root, so its blur and change handlers run. If the target takes no focus, the shortcut calls `blur()` on the deepest focused element instead: a private `deepestActiveElement` helper follows `shadowRoot.activeElement` through open roots. After the click, focus goes back to that element, unless the click moved focus somewhere other than the target or body.
**Test:** `save shortcuts commit a draft inside a shadow root` (4 cases: Ctrl+S and Ctrl+Enter, open and closed root). The test field emits `summer-change` from the inner input's `change` event. All 4 cases fail on the previous code. happy-dom behaves like the spec and Firefox here: `blur()` on the host is a no-op.
**Status:** fixed: requires human verification (focus and blur behaviour; it has only been checked in happy-dom, not in Firefox)
### RB-WR-02: A scope name is validated trimmed but registered untrimmed
**Files modified:** `modules/cabana/token_scopes.go`, `modules/cabana/token_scope_name_test.go`
**Commit:** a5ce43f
**Applied fix:** `validatePluginTokenScope` now checks `scope.Name` exactly as `compileTokenScopes` registers it, with `pact.ValidTokenScopeName`. A padded name now fails boot instead of being trimmed. This matches the pact rule, and the docs claim that "Both catalogs check names with `pact.ValidTokenScopeName`" now holds. The duplicate check uses the same key that is registered. No doc change was needed.
**Test:** `TestTokenScopeNameParity` now also covers `" read"`, `"read "`, `"read\n"` and `"\tblog:read"`. On the old code it fails for all four.
### RB-IN-01: The SPA URL check passes `/<TAB>/host`
**Files modified:** `admin/src/components/account/AccountContributions.vue`, `admin/tests/account/AccountContributions.test.ts`
**Commit:** 633d450 (dist: 34dce1c)
**Applied fix:** `safeUrl` now refuses any ASCII control character (U+0000-U+001F, U+007F) first, as Go's `url.Parse` does on the server. An absolute http(s) URL must also parse with a host. With both changes the SPA and the server apply the same rule, as the comment says.
**Test:** `a "<url>" URL answer renders no link and never opens a window`, for `/\t/evil.example/x`, `/\n/evil.example`, `/\r\n/evil.example`, `/x\u0000` and `https://`. All fail on the old code.
### RB-IN-02: An action whose Applies fails disappears with no signal
**Files modified:** `modules/cabana/account_contributions.go`, `modules/cabana/account_contributions_hardening_test.go`, `modules/cabana/admin_openapi.go`, `modules/cabana/README.md`, `docs/backend/users-and-permissions.md`, `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`
**Commit:** 81342f1
**Applied fix:** When an action's `Applies` fails, the action is still dropped and `view.Error = true` is now set on its section. The SPA already shows the section error line next to a successful status. The field description of `AccountContributionView.Error`, the cabana README (feature entry and route table) and the My account docs are updated. The OpenAPI document and the schema types are regenerated; this was a description change only.
**Test:** `TestAccountContributionsAppliesFailureFlagsSection` fails on the old code. `TestAccountContributionsIsolation` still passes.
### RB-IN-04: The custom-element-in-form rule also mutes Esc on focusable non-field widgets
**Files modified:** `admin/src/app/hotkeys.ts`
**Commit:** 89b85f5 (dist: 34dce1c)
**Applied fix:** The trade-off is now documented in the header comment and in the `isEditableTarget` doc comment. The optional narrowing to plugin field types was not done. With it, Esc would leave the form while the admin types into a `type: widget` element with an input in a closed root. This is recorded in deferred-items.md.
## Skipped Issues
### RB-IN-03: The CLI `admin:token:revoke` fires no revoke event
**File:** `modules/cabana/token_commands.go:225-255`
**Reason:** The code fix stays deferred because it is not cheap. Calling `fireTokenRevoked` from the command would do nothing. The command runs in its own process, which does not mount pier, so no listener receives the event and nothing is published to the store a running server reads. Closing the gap needs the command to publish into pier's store directly, which is a seam across modules. The reviewer's alternative, documenting the gap, was applied in 4d7eb9d: the cabana README events entry and the command table now say that only the My account revoke fires the event, and that the CLI-revoked token's sessions end at its next verification. The existing deferred-items entry was updated in 0b0cae5.
**Original issue:** The operator revoke command fires nothing, so the token's open sessions stay until the next bearer re-verification. The README did not say this applies to the HTTP route only.
---
_Fixed: 2026-10-23T18:39:41Z_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_
# Lane C
## Phase 19.7: Code Review Fix Report (re-review, lane C)
**Fixed at:** 2026-10-23
**Source review:** 19.7-REREVIEW.md, section "# Lane C" only (RC-CR-01, RC-WR-01..02, RC-IN-01..07)
**Iteration:** 1
**Summary:**
- Findings in scope: 10
- Fixed (fully or in their cheap part): 8 (RC-CR-01, RC-WR-01, RC-WR-02, RC-IN-01, RC-IN-02, RC-IN-03, RC-IN-04, RC-IN-05), plus the cheap part of RC-IN-06
- Deferred: RC-IN-06 (boot-order part), RC-IN-07 (release-time); also the optional parts of RC-CR-01 and RC-IN-01
Repositories: U = sm-user-plugin (sm-demo-app/plugins/golem15/user), O = sm-oauthserver-plugin, J = sm-journal-plugin, V = vue-demo-app, D = sm-demo-app, F = summercms.go.
Verification ran in the main checkouts (`workflow.use_worktrees: false`, no worktree created). Each repo's `go vet ./...` and `go test -short ./...` were green before every commit, and the Docker-backed tests of every changed package ran against Postgres (testcontainers). For each fix the new test was also run against the old code (stash, or a temporary mutation that was then restored) and failed there.
## Fixed Issues
### RC-CR-01: Self-service password change kept OAuth/MCP grants refreshed after the session's iat
**Status:** fixed: requires human verification (logic)
**Commits:** U 30f7a01 (fix + README + test), O 5624e36 (test), O 95ccaec (README test line)
**Applied fix:** `ChangePassword` fires `AccessRevokedEvent` with `Before = time.Now()` (the request instant). `tokens_valid_after` is still `iat - 1s`, so the calling session survives. The README now says that `Before` is the revocation instant for every reason, and that the self-service cutoff only keeps the calling session.
**Tests:**
- U `TestAccessRevocation` / "ChangePassword revokes as of the request and keeps the calling session". The session is crafted with an iat 20 minutes old; the test asserts Before >= request start, the stored cutoff equals iat-1s, and `/fetch` still works with the session. On the old code it fails ("Before ... is not the instant of the request").
- O `TestMCPGrantRevocation` / "a password change ends a grant rotated after the session's iat". It goes through `POST /change-password` after the agent rotated its grant, then asserts all rows are revoked, the verifier refuses the rotated token, the rotated refresh is refused and the session works. On the old code it fails with "1 revoked, 3 live".
### RC-WR-01: backend grants could act as an administrator the consent did not show
**Status:** fixed: requires human verification (logic, concurrency)
**Commits:**
- F c37c6ce `fix(19.7): RC-WR-01 ...`. Adds `pact.MCPGrant.BackendUserID`. pier's `resolveOAuth` and `refreshBackend` (after a link event) create a backend subject only when the current link names that id. Zero means no backend subject (fail closed). The pact README, pier README and docs/services/mcp.md were updated in the same commit, and the docs checker passes.
- O 105d263: migration `202610230001_add_golem15_oauthserver_backend_user_id` adds a nullable `backend_user_id` to the mcp_codes, access_tokens and refresh_tokens tables (no backfill: pre-migration backend grants stop acting as an admin until reconnected, as the README upgrade note says). Consent decide takes `backend_admin` (the shown login) and answers 409 `backend_link_changed` when it is not the admin linked now (moved, removed, or not named while linked). The linked admin id goes through `classes.WithConsentedBackend(ctx)` into `MarkIssued`. The code exchange and every rotation copy it onto the tokens, and the verifier returns it. `OnBackendLinkChanged` also deletes unexchanged backend MCP codes. `revokeUserGrants` takes per-user advisory locks (exclusive, ascending id); the code exchange and the refresh rotation take the same lock shared before their row lock, so a rotation in flight commits first and its successor gets revoked.
- O b6a9586: `Mint`/`Create` fall back to the context's consented admin for direct minting.
- V 3c53182 + D 054c15a (pointer bump): `/connect` sends `backend_admin` through the new `app/lib/consent.ts`. On a 409 it reloads the request and shows `connect.linkChanged` (en/pl).
- D a6be849: the demo e2e mint helpers bind backend grants to the linked admin, and the consent helper names the shown admin.
- J 521df9a: `TestJournalPromptWrites`'s fake grants now carry `BackendUserID`. The gate caught this: pier's fail-closed rule had broken the fixture.
**Tests:**
- F `TestToolsList` / "a backend grant acts only as the administrator it was consented for". Grants bound to admin 43, and to no admin, list no backend tool and cannot call it.
- F `TestFanOut` / "link change". A relink to another admin drops the backend subject; relinking the consented admin restores it.
- O `TestMCPBackendBinding`, 5 subtests: the decide 409 matrix; the binding travels code → exchange → rotation → verifier; direct mint honours the context; a link change deletes the backend codes so the exchange answers invalid_grant while a non-backend code still works; a revocation racing a rotation waits and revokes the successor. On the old code all four original subtests fail; the race subtest even hit "deadlock detected (SQLSTATE 40P01)". The direct-mint subtest fails without b6a9586.
- D `TestMCPEndToEnd` / "a grant consented for another administrator has no backend tools".
- V `app/lib/consent.test.ts` (5 tests).
- `TestMCPConsentSmoke` was updated to send `backend_admin`.
### RC-WR-02: verifier ban refusal untested
**Status:** fixed
**Commit:** O 89c9ec8
**Applied fix:** the ban subtest now writes `user_throttle.is_banned` directly, checks that `tokens_valid_after` stays NULL, asserts both the verifier and the rotation refuse, and that unbanning restores both. A new subtest refuses the rotation of a token created before the cutoff and leaves its rows untouched. The doc comment and README were corrected.
**Tests:** `TestMCPVerifierRevocation` / "a ban alone refuses the verifier and the rotation until unbanned" and "the refresh rotation refuses a token created before the cutoff". Mutation checks: removing the verifier ban check fails at line 152; removing only the rotation ban branch fails at line 154; removing the rotation cutoff check fails at line 170.
### RC-IN-01: link code accepted from the query string on POST routes
**Status:** fixed (the nginx part is deferred)
**Commit:** U 92676ba
**Applied fix:** new unexported `readBodyFields` (a JSON body or `r.PostForm`, never the query string), used by `BackendLinkShowPost` and `BackendLinkConfirm`. README updated.
**Test:** `TestBackendLinkShowPost` / "a code in the query string is ignored". Show with `?code=` and no body, an empty JSON body or a form without the code gives 404; confirm with `?code=` gives 404; a form body still works. On the old code it fails ("= 200 ... want 404").
### RC-IN-02: the auth_time one-second blind spot
**Status:** fixed (documented, comparison unchanged as the design accepts it)
**Commit:** U b929ba9. The doc comment on `ConfirmLinkRequest` and an inline note now state the blind spot and why it is accepted.
### RC-IN-03: logo and avatar URL fallback ignored the public path
**Status:** fixed
**Commit:** U 9730110. `OrganisationLogoURL` and `AvatarURL` fall back to `f.URL()` (attach.PublicURL). README updated.
**Tests:** `TestAvatarURL` (its assertion pinned the old behaviour and now asserts the public URL) and `TestOrganisationLogo` (new failing-bucket check). Both fail on the old code ("/fac/... want /storage/uploads/fac/..."). `TestAvatarURL` was added to the gate's USER_TESTS.
### RC-IN-04: nginx /mcp cap equal to pier's
**Status:** fixed
**Commit:** D 40ed518. `client_max_body_size 5m;` on `location = /mcp`; `scripts/check-deploy.sh` requires the new value. `check-deploy.sh` passes, and it refuses the old config.
### RC-IN-05: test gaps
**Status:** fixed
**Commits:**
- U ebe408f: `TestOrganisationLogo` asserts the blobs exist before the delete and that all keys under both partition directories (originals and thumbnails) are gone after the commit. A mutation (DeleteKeys disabled) fails ("blobs left after the delete"). The listed U Postgres tests now call `t.Skip("Postgres")` under -short.
- O 9813c1d: the same `t.Skip` change in `mcp_revocation_test.go`.
- D 09c3d31: the same change in `TestMCPRevocationEndToEnd`.
- V 9c247ab + D 036919c (pointer bump): `linkCode.test.ts` pins `#code=<code>&next=/x` returning the code (the re-review's ruling) and documents it in `readLinkCode`.
### RC-IN-06 (cheap part): MintAPIToken deprecation
**Status:** partially fixed; the boot-order part is deferred
**Commit:** U 9892404. `MintAPIToken` is marked `Deprecated:` in favour of `MintAPITokenFor`, and the README says so. No internal caller is left; consuming apps still compile.
### Gate
F f824d93 `test(19.7)` adds `TestMCPBackendBinding` to OAS_TESTS and `TestAvatarURL` to USER_TESTS.
## Skipped Issues
### RC-IN-06 (boot-order part): fallback widened before the phrase-key check
**File:** `U classes/api_token_scopes.go:125`, `U plugin.go:127-146`
**Reason:** deferred. Moving the add after the check needs either a new exported seam or a behaviour change to the exported `CompileAPITokenScopes` in a core plugin. A failed boot ends the process, so the widened set is never used in production.
**Original issue:** `CompileAPITokenScopes` widens the process-wide MintAPIToken fallback before boot checks the phrase keys.
### RC-IN-07: oauthserver builds only through go.work
**File:** `O go.mod:6`
**Reason:** deferred as a release-time item, per instructions. The requires were not bumped. The O release checklist must bump sm-user-plugin to the new U tag and pass `GOWORK=off go build ./...`.
**Original issue:** O uses U APIs that v0.1.10 lacks.
### Optional parts deferred
- RC-CR-01 optional: base the session cutoff on `auth_time`, and have `/refresh` and the jwt guard compare it with the cutoff. This changes session revocation in a core plugin and bouncer, and the gap predates the phase.
- RC-IN-01 optional: nginx `access_log off` (or a log_format without `$request_uri`) for `/_user/api/v1/backend-link/` until the deprecated GET route goes in a major release. This is a demo deploy change.
All deferrals are recorded in F deferred-items.md (4d5a09a, 1fe553e). That file also marks the earlier `readLinkCode` entry resolved, and records that the demo's oauthserver working-tree checkout moved from 671513d to the canonical b6a9586 (detached, fast-forward, pointer uncommitted). The demo e2e tests need that checkout, because pier now requires the recorded consented admin.
## Gate tail (bash scripts/check-phase19.7.sh --all, exit 0)
```
PASS go
PASS plugins
PASS consumers
PASS admin
PASS docs
PASS marker
PASS docker
Phase 19.7 gate passed WITH UNRELEASED PINS: the release pins are not yet proven (plugins/golem15/journal plugins/golem15/oauthserver plugins/golem15/user)
```
---
_Fixed: 2026-10-23_
_Fixer: Claude (msd-code-fixer)_
_Iteration: 1_

View File

@@ -0,0 +1,538 @@
---
phase: 19.7-generic-mcp-module
reviewed: 2026-10-23
depth: deep
kind: re-review of the gap closure (plans 06-09)
lanes: [A framework MCP core, B cabana and admin SPA, C plugins and demo]
files_reviewed: 129
findings:
critical: 1
warning: 10
info: 17
total: 28
status: issues_found
---
# Phase 19.7 re-review of the gap closure
This re-reviews the changes made by gap-closure plans 06-09, after the first deep review in `19.7-REVIEW.md`. That file is left unchanged because the plans and security documents cite its finding ids. Three deep lanes ran in parallel over: the framework since fef1cbb, sm-user-plugin since 32f9259^, sm-oauthserver-plugin since 671513d^, sm-journal-plugin since b129902^, vue-demo-app since 0125e29^ and sm-demo-app since 00f57bf^. Finding ids carry the lane prefix (RA-, RB-, RC-). Each lane also rules on every earlier critical and warning finding of its lane ("Earlier findings").
| Lane | Critical | Warning | Info | Earlier findings not fully closed |
|------|----------|---------|------|-----------------------------------|
| A framework MCP core | 0 | 6 | 6 | A-WR-07 partial; A-WR-04 and A-WR-06 fixed with regressions |
| B cabana and admin SPA | 0 | 2 | 4 | B-WR-02 partial |
| C plugins and demo | 1 | 2 | 7 | C-CR-01 partial, C-WR-02 partial |
# Lane A
## Phase 19.7 (lane A re-review): Code Review Report
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 45
**Status:** issues_found
## Summary
I re-reviewed lane A's gap-closure changes (`git diff fef1cbb..HEAD`) and the code they touch: the session reaper, the atomic per-binding reservation (memory and Redis Lua), the lock-scope rework, fingerprint-based widening, the stateless listen re-check and eviction close, the Redis warm-up, the narrowed revocation events, the dispatch hardening, the catalog boot checks, bouncer's `auth_time`, towel's client address and the gate script. I traced the calls into go-sdk v1.8.0 (`Server.Sessions`, `changeAndNotify`/`notifySessions`), into cabana (`fireTokenRevoked`, `ResolveBackendToken`, `BackendPrincipal`) and into the publishers of `pact.MCPSubjectChanged` in the OAuth server and user plugins. `go vet -tags summercms_mcp` on pier, surf and bouncer is clean. `go test -race -short ./modules/pier` passes.
Every earlier lane-A critical and warning has a fix in the code, not only in the plans. The reaper closes expired local copies and logs `expired`. Reserve is atomic in both stores. Path values are cleaned and required. Raw dispatch errors are logged, not returned. Personal tokens without `TokenID` are refused. The Redis store is warmed at mount. Eviction closes the evicted server's sessions. I found no identity or guard bypass. Widening adds tools to a cached server, but the receiving middleware still filters every list and call for the requesting identity, so no request gains reach it does not have. Revocations narrowed by `TokenID` and `ClientID` match the events cabana and the plugins publish. The `ClientID == ""` condition stops an admin-token revoke from also ending an OAuth session that acts as the same admin.
The new defects are in the concurrency and caching that the fixes introduced:
- **A-WR-07 is only partly fixed.** Event fan-out still takes each per-session lock while it holds the router-wide lock. Widening and recomputing hold that per-session lock across guard I/O, and widening runs with no deadline. One slow check plus any event still stalls the instance.
- **The fingerprint includes the OAuth access-token id.** Every token rotation re-filters the session. A stateless server shared by two live tokens of one binding flips its tool set on every request, which races calls into "unknown tool" errors.
- **A transient guard error during widening removes tools from a live session** and records the new fingerprint, so the tools stay gone until the next rotation or event.
- **Capacity eviction now closes live listen streams and in-flight calls.** With more concurrent bindings than the LRU capacity, the stateless path keeps forcing clients to reconnect.
- **Prompt `Render` errors still reach the client raw** (the same class as A-WR-08).
- **The gate passes with demo submodules ahead of their go.mod pins** by non-test commits unless `PHASE197_RELEASED=1`. The final "gate passed" line gives no caveat.
## Earlier findings
| Id | Verdict | Evidence |
|----|---------|----------|
| A-CR-01 | fixed | `modules/pier/pier.go:143-148` starts `reap`. `modules/pier/session.go:483-531`: `reapOnce` snapshots `sr.local`, calls `store.Get` for each entry, then `closeLocal` and logs `expired`. A store error keeps the copy. `reapInterval` (`:535-537`) is between 1 s and 1 min. The memory store's `Get` honours expiry (`store_memory.go:44-53`). The Redis path relies on PX TTLs. `reaper_test.go` covers both stores. The reaper ctx is never cancelled in production (no shutdown hook, documented at `pier.go:63-67`). |
| A-WR-01 | fixed | `modules/pier/dispatch.go:119-128` refuses empty, `.` and `..` segments in every path value, and `/` in a single-segment value. Wildcard values are escaped per segment (`:376-383`). |
| A-WR-02 | fixed | Boot: `modules/pier/catalog.go:220-224` refuses a path field that is not in `schema.Required`. Runtime: `dispatch.go:142-148` refuses a missing path value. |
| A-WR-03 | fixed | `modules/pier/session.go:143-173` reserves before connecting and releases on a failed connect or initialize. The memory `Reserve` counts and writes under `m.mu` (`store_memory.go:101-117`). The Redis `reserveScript` prunes, counts, SETs and SADDs in one EVAL (`store_redis.go:144-182`). `lazyStore.Reserve` forwards to it (`:326-339`). The non-reserver fallback (`session.go:156-166`, `:187-193`) is still check-then-act, but only a test-wrapped store reaches it (RA-IN-04). |
| A-WR-04 | fixed, with regressions | `modules/pier/events.go:131-141` (`widen`) is called from `session.go:332-335` and `stateless.go:185-190`. The fingerprint is `identity.go:85-116`. The new defects are RA-WR-02 and RA-WR-03. |
| A-WR-05 | fixed | `modules/pier/pier.go:146-148` runs `ls.warm` at mount with a backoff (`store_redis.go:279-294`). `route` kicks the open on every request (`stateless.go:248-250`). `open` subscribes with `context.Background()` (`pier.go:178`). |
| A-WR-06 | fixed, with a regression | The listen re-check is at `modules/pier/stateless.go:98-123`. Capacity eviction closes the evicted server's sessions (`:151-167`). The new defect is RA-WR-04. |
| A-WR-07 | partially fixed | `connect` now runs without `sr.mu` (`session.go:174-186`, `:308-331`), and the stateless build runs without `sp.mu` (`stateless.go:137-150`). But `matchingFunc` still locks each `ls.mu`/`e.mu` while holding `sr.mu`/`sp.mu` (`events.go:190-203`, `stateless.go:205-218`), and `widen`/`recomputeSession` hold `ls.mu` across guard I/O (`session.go:332-335`, `events.go:98-109`, `:138`). See RA-WR-01. |
| A-WR-08 | fixed (dispatch) | `modules/pier/dispatch.go:179-189` logs the cause and returns a fixed text. Only `ErrMCPTokenInvalid` and `errSubjectUnavailable` (`backend_guard.go:73-86`) get specific texts. The prompt path still returns raw errors (RA-WR-05). |
| A-WR-09 | fixed | `modules/pier/identity.go:267-272` refuses `TokenID == 0`. The contract is documented at `pact/mcp.go:241-249`. |
## Warnings
### RA-WR-01: Event fan-out still nests per-session locks under the router-wide lock, while those locks are held across guard I/O with no deadline (A-WR-07 not closed)
**File:** `modules/pier/events.go:190-203`, `:98-109`, `:131-141`; `modules/pier/stateless.go:205-218`, `:185-190`, `:171-181`; `modules/pier/session.go:295-297`, `:332-335`
**Issue:** The fix moved `connect` out of `sr.mu`, but it did not change the lock order. `sessionRouter.matchingFunc` takes `sr.mu` and then each `ls.mu` in turn. `statelessPath.matchingFunc` does the same with `sp.mu` and `e.mu`. Meanwhile:
- `lookup` holds `ls.mu` across `widen`, which calls `catalog.allowed` (database-backed `MCPAllows`/`MCPAllowsMethod`).
- `recomputeSession` and `recomputeStateless` hold `ls.mu`/`e.mu` across `refreshBackend` (the link resolver) and `catalog.allowed`.
- `widen` uses `context.WithoutCancel(ctx)`, which removes the request's deadline as well as its cancellation. `recomputeSession` uses `context.Background()`.
**Failure scenario:**
1. Session S's request widens after a token rotation (the fingerprint changed, see RA-WR-02), or an event is recomputing S, while the database is slow.
2. A second event arrives. The memory store runs every event on its own goroutine (`store_memory.go:127-135`), so two events in flight at once is normal: one role change fires one event per user.
3. That event's `matchingFunc` holds `sr.mu` and blocks on `S.ls.mu`.
4. Every stateful request on the instance now blocks: `lookup` at `session.go:295`, `serveNew` at `:184`, `closeLocal`, the reaper and the `connect` goroutine. The stateless path behaves the same way through `sp.mu` and `hit()`.
5. The guard check has no deadline. If the database waits on an exhausted pool, the stall lasts until a connection frees. The endpoint is down instance-wide.
`TestLockScope` only blocks an initialize, which no longer holds a lock. It never combines a blocked `widen` or recompute with an event, so it does not cover this path.
**Fix:** Never wait on a per-session lock while holding the router lock. Snapshot the entries under `sr.mu`, release it, then evaluate the match:
```go
func (sr *sessionRouter) matchingFunc(match func(Identity) bool) []*localSession {
sr.mu.Lock()
all := make([]*localSession, 0, len(sr.local))
for _, ls := range sr.local {
all = append(all, ls)
}
sr.mu.Unlock()
var out []*localSession
for _, ls := range all {
ls.mu.Lock()
hit := match(ls.identity)
ls.mu.Unlock()
if hit {
out = append(out, ls)
}
}
return out
}
```
Do the same in `statelessPath.matchingFunc`. Alternatively, keep `identity` behind its own small mutex or an `atomic.Pointer[Identity]` that is never held across I/O. Also give `widen` and the recomputes a bounded context, for example `context.WithTimeout(context.WithoutCancel(ctx), d.cfg.CallTimeout)`. Extend `TestLockScope` with a guard that blocks inside `widen`, plus a concurrent `MCPSubjectChanged`, and assert that another session's call still finishes.
### RA-WR-02: The fingerprint includes the OAuth access-token id, so a shared stateless server flips its tool set between two live tokens of one binding
**File:** `modules/pier/identity.go:85-116` (`TokenID` at `:103`), `:216-233`; `modules/pier/stateless.go:128-136`, `:185-190`; `modules/pier/events.go:131-141`
**Issue:** For an OAuth identity, `Frontend.TokenID` and `Backend.TokenID` hold `grant.TokenID`, the access-token row id (`sm-oauthserver-plugin/classes/mcp_verifier.go:110`). It changes with every refresh. The stateless LRU keeps one server per binding (`oauth:<client>:<user>`), and every request runs `refresh` → `widen` with that request's identity. When two different tokens of the same binding are live at once, the fingerprint differs on every alternate request. Two cases are common:
- The same static client signed in on two devices.
- After a re-consent with wider scopes, an older narrower token is still valid on another device.
Each flip runs `catalog.allowed` (guard I/O) under `e.mu` (feeding RA-WR-01). When the scopes differ, `applyDiff` adds and then removes the write tools on every alternation.
**Failure scenario:** Token A (`read write`) calls `create_post`. The receiving middleware checks A's own allowed set and passes the call. Meanwhile a request with token B (`read`) runs `widen` on the same server and calls `RemoveTools("create_post")`. The SDK's tools/call handler then answers "unknown tool" for A's legitimate call. Every flip also queues a `list_changed` notification for every listen stream of the binding. Before the gap closure the tool set was frozen. Now it flaps.
**Fix:**
- Leave token ids out of the fingerprint. The guard decision depends on scopes, subjects and the ceiling, not on which token row carried them.
- Key the stateless LRU by `binding + "|" + fingerprint`, as the earlier review suggested, so identities with different reach never share or mutate one server.
- At minimum, never remove tools in `widen` on the stateless path: the receiving middleware already filters down per request.
### RA-WR-03: A transient guard error during widening permanently narrows a live session
**File:** `modules/pier/events.go:131-141`; `modules/pier/filter.go:33-47`, `:49-64`
**Issue:** `catalog.allowed` turns a provider error into "not allowed" (logged). `widen` then runs `applyDiff`, which removes every tool or prompt whose check errored, and stores the new fingerprint. Later requests with the same fingerprint skip `widen`. The receiving middleware can only filter down, so the removed tools cannot come back. They stay hidden until the fingerprint changes again or a subject event recomputes the session. Server construction already had a weaker form of this, but it ran once. Because of RA-WR-02, widening now runs on every OAuth token rotation.
**Failure scenario:** A pool timeout during the widen at a token rotation hides every backend tool of a long-running agent session for a whole access-token lifetime. Calls to those tools fail as "not available to this caller" although nothing changed.
**Fix:** Let `widen` tell an error apart from a refusal. For example, add an `allowedStrict` that returns an error, and on error leave the server untouched and leave `*fp` unchanged, so the next request retries:
```go
tools, prompts, err := p.catalog.allowedStrict(bounded, id)
if err != nil {
logger().Warn("pier: widening deferred", "error", err)
return // keep *fp, retry on the next request
}
applyDiff(...)
*fp = next
```
### RA-WR-04: LRU capacity eviction now ends live listen streams and in-flight calls, so the stateless path churns past capacity
**File:** `modules/pier/stateless.go:151-167`, `:192-197`, `:55-62`
**Issue:** A `subscriptions/listen` POST calls `getServer` once, when it starts. A quiet listener never touches its entry again, so the entry drifts to the back of the LRU (capacity `max_per_binding * 16`, 320 by default). The A-WR-06 fix makes eviction call `closeSessions` on the evicted server. That ends the listen stream, and it also ends any in-flight tools/call of that binding: the per-request `ServerSession` is in `srv.Sessions()`, and closing its connection cancels the dispatched route's context mid-handler.
**Failure scenario:** More than 320 distinct bindings are connected with listen streams. Each new binding's first request evicts the oldest listener. That client reconnects and evicts the next one, and so on. Every stream keeps reconnecting, list changes sent during the gaps are lost, and a write tool call caught by an eviction is cancelled partway, so the agent gets an error for a request whose effect may or may not have committed.
**Fix:**
- Do not count entries with an open listen stream as evictable. For example, keep a per-entry count of active listens, raised in `ServeHTTP` around `sp.handler.ServeHTTP` for listen requests, and skip such entries when evicting.
- Or move such an entry to the front on every bearer re-check tick.
- Or leave the streams open on eviction (the 1-minute bearer re-check already bounds them). Then a revocation must also close the streams it can no longer find, for example through a binding-to-streams index that eviction does not clear.
- Never close the sessions of a request that is still being served.
### RA-WR-05: Prompt `Render` errors are returned to the MCP client verbatim
**File:** `modules/pier/session.go:719-722`
**Issue:** `promptHandler` returns `pr.decl.Render`'s error as is. The SDK turns it into the JSON-RPC error message. A plugin's `Render` that reads the database (to list drafts, look up a record or build context) can then send pgx or gorm text, which may include the host, database and user names, to the outside client and its transcript. This is the leak A-WR-08 closed for dispatch, left open for prompts.
**Fix:**
```go
messages, err := pr.decl.Render(ctx, args)
if err != nil {
logger().Warn("pier: prompt render failed", "prompt", pr.name, "error", err)
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: "pier: the prompt could not be rendered"}
}
```
### RA-WR-06: The gate passes while demo submodules carry non-test commits that the go.mod pins do not ship
**File:** `scripts/check-phase19.7.sh:957-961`, `:977-982`, `:1158-1170`
**Issue:** `demo_pins` now accepts a submodule that is ahead of its pin by non-test commits whenever the canonical checkout's HEAD contains them. It fails only with `PHASE197_RELEASED=1`, and the default is 0. The demo's own `check-pins.sh` exists to prevent exactly this: "they must be the same code, or the tests pass against something that is not shipped". With the default, the consumers stage runs the demo's builds and tests against the submodule code, while a release build (GOWORK=off) compiles the older pinned version. `run_all` still prints "Phase 19.7 gate passed". The acceptance is reported only as a mid-stage `pins:` line. The canonical check also accepts any commit on whatever branch the canonical checkout is on, pushed or not.
**Failure scenario:** The phase is marked verified on "gate passed". Then the demo is tagged and deployed with the old pins, without the gap-closure plugin fixes the tests exercised.
**Fix:**
- Make the release check fail closed by default: require an explicit `PHASE197_ALLOW_UNRELEASED=1` to accept unreleased drift.
- When drift was accepted, end `run_all` with a non-pass verdict, for example `Phase 19.7 gate passed WITH UNRELEASED PINS (not releasable)`, and a distinct exit code.
- Optionally require that the commit exists on the canonical checkout's upstream (`git branch -r --contains`).
## Info
### RA-IN-01: `lazyStore`'s doc comment says nothing dials at mount
**File:** `modules/pier/store_redis.go:231-233`
**Issue:** The comment still says "nothing dials at mount (route:list and tests never dial)". `Mount` now starts `warm`, which dials Redis in the background (`pier.go:146-148`). If a CLI command mounts pier while Redis is unreachable, it also logs "session store not open yet, retrying".
**Fix:** Reword the comment to match `Mount`'s and decide whether non-serving commands should skip `warm`.
### RA-IN-02: Stream re-checks resolve the bearer without the client address
**File:** `modules/pier/session.go:438`; `modules/pier/stateless.go:114`
**Issue:** The bearer middleware puts `towel.WithClientIP` on the resolver context (`pier.go:254`), but the GET-stream and listen re-check goroutines call `identities.resolve(ctx, raw)` on a context without it. A re-check that fails, for example a revoked admin token, is logged by `cabana.ResolveBackendToken` with an empty remote. That is the case B-IN-04 set out to make traceable.
**Fix:** Capture `p.clientIP(r)` when the stream starts and resolve with `towel.WithClientIP(ctx, ip)`.
### RA-IN-03: `reserveScript` touches keys it does not declare
**File:** `modules/pier/store_redis.go:144-160`
**Issue:** The script runs `EXISTS` on `ARGV[1] .. m` for every set member, so it touches keys missing from `KEYS`. A standalone `redis.Client` accepts this. Redis Cluster refuses it (cross-slot), and so do ACLs restricted by key pattern (undeclared keys). The binding set and the record key would not hash to one slot anyway.
**Fix:** Document that the store needs a standalone Redis, or put a hash tag on the binding in both key names (`mcp:{<binding>}:...`) and keep the per-member keys in the same slot.
### RA-IN-04: The non-reserver fallback in `serveNew` is still check-then-act and briefly exposes an unrecorded session
**File:** `modules/pier/session.go:156-166`, `:184-193`
**Issue:** Both shipped stores (and `lazyStore`) implement `reserver`, so only a test wrapper (`WrapStore`) reaches this branch. On it, the cap is TOCTOU, and the session sits in `sr.local` before its record is written, so a reaper pass in between closes it as expired.
**Fix:** Make `reserver` part of `Store`, or delete the fallback and refuse a store without `Reserve` at mount.
### RA-IN-05: A backend token whose scopes no longer resolve gets "account unknown or deactivated"
**File:** `modules/pier/backend_guard.go:99-103`, `:79-86`; `modules/pier/dispatch.go:184-185`
**Issue:** `principal` returns `nil, nil` when `ScopeLimitFor` reports `known == 0`, for example when a scope's plugin was removed. `MCPDispatch` then answers `errSubjectUnavailable`, and the agent reads that the administrator's account is unknown or deactivated, which is false.
**Fix:** Return a distinct sentinel, for example `errScopeUnavailable` with "the token's scopes no longer grant this route", and map it in `dispatch.go`.
### RA-IN-06: `demo_pins` passes when `check-pins.sh` exits non-zero with no output
**File:** `scripts/check-phase19.7.sh:962-990`
**Issue:** With `rc != 0` and empty output, the here-string feeds one empty line, `[[ -z "$line" ]] && continue` skips it, and the function returns 0. The current `check-pins.sh` prints a line on every failure path, so this is unlikely, but the check fails open.
**Fix:** After the loop, refuse unless at least one mismatch line was matched and accepted: track `accepted=1` inside the branch, and `[[ -n "$accepted" ]] || { echo "refuse: check-pins failed without a mismatch line" >&2; return 1; }`.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane B
## Phase 19.7 (lane B re-review): Code Review Report
**Reviewed:** 2026-10-23T19:30:00Z
**Depth:** deep
**Files Reviewed:** 24
**Status:** issues_found
## Summary
This re-review covers the lane B gap-closure changes between fef1cbb and HEAD (commits 9eedfbb, 24cf6cd, 44835e4, 88559d5 and the plan 09 tests). I re-read the code each fix touches and the code it calls:
- the pier event path: `listenEvents`, `handleEvent`, `revokedMatch`, and how identities are resolved in `identity.go`;
- `lagoon.AfterCommit` with no transaction;
- `towel.ClientIP` with a nil ctx;
- `revokeAPIToken`, `normalizeTokenScopes` (api_tokens.go:135-160);
- `FormView`'s `<form>` wrapper and save button `disabled` conditions;
- `PluginField`'s `summer-change` protocol;
- every i18n key the SPA component uses, in en, pl and the fixture.
Verified correct:
- **Token revoke event.** `fireTokenRevoked` is called with `row.ID`. The pier `Event` keeps `TokenID` through the store (`token_id` json tag). `revokedMatch` matches only subjects with `TokenID == ev.TokenID && ClientID == ""`. An OAuth backend subject always has `ClientID` set (identity.go:227-233), so OAuth sessions survive. The revoke route has no transaction, so `AfterCommit` fires at once, which is correct.
- **URL rule (server).** `safeAccountActionURL` refuses `javascript:`, `data:`, `vbscript:`, `//host`, `/\host`, `https:host` and `http:/host`, plus a leading space or tab/newline, because Go's `url.Parse` rejects control characters. I confirmed this by running the function.
- **Per-section isolation.** A failing Status sets `error` (`omitempty`, so this only adds data). A failing Applies drops only that action. The SPA toasts on a failed GET and keeps the sections already shown.
- **OpenAPI.** `scripts/check-admin-openapi.sh --check` exits 0, and `scripts/check-admin-dist.sh` reports that the dist matches.
- **Phrase keys.** `form.action_prompt_invalid`, `myaccount.action_confirm`, `myaccount.action_stale` and `myaccount.section_error` sit under the right YAML sections in en and pl and are in the fixture.
- **Logging.** `ResolveBackendToken` logs the remote address on all seven paths, and pier puts the address on the context (pier.go:254).
- **Frontend scope seam.** cabana never reads `HasFrontendTokenScopes` (grep).
- **No raw HTML.** `AccountContributions.vue` has no `v-html` and uses text interpolation only.
- **Checks run.** `go vet`, the targeted cabana `-short` tests, pact and pier `-short`, `TestDocsTree`, the two vitest files and `vue-tsc` all pass.
No blockers. Two warnings:
- The draft-commit fix for keyboard saves does not reach a focused input inside a plugin field type's shadow root.
- The "one shared name rule" for token scopes is not actually one rule in cabana, because cabana trims the name before checking it and then registers the untrimmed name.
## Earlier findings
| ID | Verdict | Evidence |
|----|---------|----------|
| B-WR-01 | fixed | `admin/src/app/hotkeys.ts:121-124` (`originOf` uses `composedPath()[0]`), `:241` (used in `onPlainKey`), `:113` (a custom element inside `form`/`[role=form]` counts as editable for closed roots; FormView wraps fields in `<form>` at `views/FormView.vue:658`). Tests: `admin/tests/app/hotkeysEditing.test.ts:71-129`. |
| B-WR-02 | partially fixed | `admin/src/app/hotkeys.ts:227-233` blurs `doc.activeElement` before `click()`. This covers light-DOM fields (the taglist; tests at `hotkeysEditing.test.ts:131-181`). When focus is inside a shadow root, `doc.activeElement` is the shadow host, and `host.blur()` does not unfocus the inner input (see RB-WR-01). |
| B-WR-03 | fixed | Server: `modules/cabana/account_contributions.go:217-221`, `:233-242`. SPA: `admin/src/components/account/AccountContributions.vue:31-39`, `:64-68`, `:128-135`. The SPA check has a defense-in-depth gap the server covers (RB-IN-01). |
| B-WR-04 | fixed | `modules/cabana/account_tokens.go:167` calls `fireTokenRevoked(..., principal.ID, row.ID)`. `modules/cabana/subject_events.go:51-65` puts `TokenID` on the event. `modules/pier/events.go:21`, `:79-93` narrow the match to that token. Test: `modules/cabana/account_token_events_test.go:20-61` (Postgres). |
| B-WR-05 | fixed | `modules/cabana/admin_openapi.go:802`, `:812`, `:831-832`. Regenerated into `admin/src/api/schema.d.ts:3033`, `:5586`. `check-admin-openapi.sh --check` exits 0. |
| B-WR-06 | fixed (one residual gap, RB-IN-02) | `modules/cabana/account_contributions.go:113-138` (error flag on the section, failing action dropped). `AccountContributions.vue:41-53` (toast on a failed GET), `:125-127` (section error line). Tests: `account_contributions_hardening_test.go:137-188`, `AccountContributions.test.ts` "a 500 on the list reload". |
Info items from the first review:
- **Fixed:** B-IN-01 (`AccountContributions.vue:74`, `:100`), B-IN-02 (`actions.go:249`, `:269`), B-IN-03 (`account_contributions.go:121`), B-IN-04 (`token_resolver.go:29`).
- **Deferred:** B-IN-05 (deferred-items.md:177).
- **Accepted:** B-IN-06, with the visible link as the fallback.
## Narrative Findings (AI reviewer)
## Warnings
### RB-WR-01: The draft-commit blur does not reach an input inside a plugin field type's shadow root (B-WR-02 residual)
**File:** `admin/src/app/hotkeys.ts:227-233`
**Issue:** The fix blurs `doc.activeElement`. When focus is on an `<input>` inside a shadow root (the plugin field types and widgets that `modules/cabana/README.md:185` explicitly allows), `document.activeElement` is the shadow host, not the input. Calling `blur()` on a host that is not itself focused does nothing in two places:
- **The HTML spec.** The unfocusing steps return early when the target is not in the current focus chain. The only exception is a host with `delegatesFocus`.
- **Firefox.** `ShouldBlur` compares the element with the focused descendant, which is the inner input.
Chromium happens to blur here, because `AdjustedFocusedElementInTreeScope() == this`. The behaviour therefore differs by browser.
`PluginField.vue:13-24` defines the value protocol as a `summer-change` event. A plugin that emits it from the inner input's native `change` event (fires on blur) still loses the typed value in Firefox:
- **Ctrl+S** saves without it.
- **Ctrl+Enter** saves without it and navigates away.
This is exactly the "plugin field type that commits on the native change event" case B-WR-02 named. The host's `focus()` afterwards is also a no-op for a non-focusable host, so focus is lost.
The new tests cover only the light-DOM taglist (`hotkeysEditing.test.ts:131-181`).
**Fix:** do what a pointer click does: focus the button before clicking it. Moving focus works in every browser and for closed roots too, because the inner input is unfocused and its blur/change fire.
```ts
// The deepest focused element reachable through open shadow roots.
let previous: Element | null = doc.activeElement
while (previous?.shadowRoot?.activeElement) {
previous = previous.shadowRoot.activeElement
}
const restore = previous instanceof HTMLElement && previous !== target && previous !== doc.body ? previous : null
// Pointer-equivalent: mousedown focuses the button, which blurs whatever had
// focus, shadow-root inputs included (blur() on the host does not).
if (restore) {
target.focus({ preventScroll: true })
}
target.click()
if (restore?.isConnected) {
restore.focus()
}
```
At minimum, descend `shadowRoot.activeElement` for open roots before calling `blur()`. Add a test with an open-shadow-root input whose `change` handler emits `summer-change`.
### RB-WR-02: A scope name is validated trimmed but registered untrimmed, so the shared name rule is not one rule
**File:** `modules/cabana/token_scopes.go:106-118` (validation), `:89-98` (registration); `modules/cabana/api_tokens.go:146-152`
**Issue:** `validatePluginTokenScope` validates `strings.TrimSpace(scope.Name)` with `pact.ValidTokenScopeName`. `compileTokenScopes` then stores `scope.Name` untrimmed, both in `compiledTokenScope.Name` and as the `byName` key. With a padded name such as `" blog:read"` or `"blog:read\n"`, three things go wrong:
- **Boot accepts it.** `pact.ValidTokenScopeName(" blog:read")` is false, so the parity this gap closure claims (P-30) does not hold. The docs say "Both catalogs check names with `pact.ValidTokenScopeName`" (`docs/backend/users-and-permissions.md:119`), and the `HasFrontendTokenScopes` doc promises that a bad name fails boot.
- **The scope is listed but can never be granted.** It appears in `GET /account/tokens/scopes` under its padded name. Token creation trims every requested name (api_tokens.go:147), looks it up as `byName["blog:read"]`, misses, and answers `scope_unavailable`.
- **The duplicate check depends on order.** The check looks up the trimmed name. If `" read"` registers first and `"read"` second, the second is not detected as a duplicate, and both register.
`TestTokenScopeNameParity` (token_scope_name_test.go:12-44) has no padded names, so it does not catch any of this. The pre-existing trim mismatch matters now because this change is what turns `ValidTokenScopeName` into the advertised single rule.
**Fix:** validate exactly what is registered. Either refuse a name that is not already trimmed:
```go
name := scope.Name
if name == "" || name != strings.TrimSpace(name) || !pact.ValidTokenScopeName(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
```
or register the trimmed `name` (`Name: name`, `cat.byName[name]`). Add `" read"` and `"read\n"` to the parity table, expecting refusal.
## Info
### RB-IN-01: The SPA URL check passes `/<TAB>/host`, which browsers resolve off-site
**File:** `admin/src/components/account/AccountContributions.vue:31-39`
**Issue:** `safeUrl` accepts any string that starts with one `/` and not `//` or `/\`. Browsers strip ASCII tab and newline from URLs, so `"/\t/evil.example/x"` and `"/\n/evil.example"` resolve to `https://evil.example/...` (confirmed with WHATWG `URL`). This is the same-origin bypass the check exists to stop.
The server check catches it, because Go's `url.Parse` rejects control characters, so this is a defense-in-depth gap only. The server and SPA rules are also documented as "the same rule" (`:27-29`), which they are not.
**Fix:** strip or refuse control characters first, e.g. `if (/[\u0000-\u001f\u007f]/.test(u)) return ''`. Or resolve with `new URL(u, location.origin)` and require `protocol` http(s) and, for relative input, `origin === location.origin`.
### RB-IN-02: An action whose Applies fails disappears with no signal
**File:** `modules/cabana/account_contributions.go:128-135`; `AccountContributions.vue:125-127`
**Issue:** A Status failure sets `error: true` and shows the section error line. An Applies failure only drops the button. If the section's Status still succeeds, the admin sees a normal section (for example "Linked to …") with its Unlink button missing and no message. That is the "fails silently" half of B-WR-06 again, for a single action.
**Fix:** set `view.Error = true` when any action's Applies fails as well (and document `error` as "status or an action could not be loaded"), so the SPA shows the section error line.
### RB-IN-03: The CLI `admin:token:revoke` fires no revoke event
**File:** `modules/cabana/token_commands.go:225-255` (interacts with `account_tokens.go:167`)
**Issue:** The My account revoke now ends that token's MCP sessions. The operator revoke command fires nothing, so the token's open sessions (and any open GET listen stream) stay until the next request's bearer re-verification refuses them. The README entry for backend subject events says a revoked personal API token fires the event, without saying this applies to the HTTP route only.
**Fix:** call `fireTokenRevoked(ctx, app, row.BackendUserID, row.ID)` after the update when the command runs with the application bus. Otherwise, document that only the My account revoke fires.
### RB-IN-04: The custom-element-in-form rule also mutes Esc on focusable non-field widgets
**File:** `admin/src/app/hotkeys.ts:113`
**Issue:** Any focused element with a hyphenated tag inside a `<form>` counts as editable. That includes `WidgetField` hosts (`components/form/fields/WidgetField.vue:61`) that are focusable but not inputs, such as a chart or a button strip. While one of them has focus, Esc does not leave the form. This is an accepted trade-off for closed roots, but it is undocumented.
**Fix:** mention it in the header comment at `hotkeys.ts:15-25`. Optionally narrow the rule to hosts rendered by `PluginField` (for example via a `data-field-type` attribute the SPA sets on plugin field elements).
---
_Reviewed: 2026-10-23T19:30:00Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane C
## Phase 19.7: Code Review Report (lane C re-review: plugins and demo)
**Reviewed:** 2026-10-23T17:19:01Z
**Depth:** deep
**Files Reviewed:** 60
**Status:** issues_found
Abbreviations: **U** = sm-user-plugin (`sm-demo-app/plugins/golem15/user`, range 32f9259^..HEAD), **O** = sm-oauthserver-plugin (671513d^..HEAD), **J** = sm-journal-plugin (b129902^..HEAD), **V** = vue-demo-app (0125e29^..HEAD), **D** = sm-demo-app (00f57bf^..HEAD), **F** = summercms.go (framework, read for call chains only).
Checks run: `go vet ./...` and `go test -short ./...` in U (with `GOWORK=off`, the module's own `replace` to the framework), O and J are green. With `GOWORK=off`, O does not build (see RC-IN-07). Docker-backed tests were not run.
## Summary
Most of the gap closure holds up when traced through the code:
- **Account link proof (C-CR-02).** The confirm handler reads `auth_time` again from the same token `jwt.auth` accepted, checks that its subject matches the authenticated principal, and refuses a token that has no `auth_time`. `bouncer.Refresh` copies `auth_time` unchanged (F `bouncer/refresh.go:141-142`). The plugin's only mint sites (`mintFor`: login, registration, activate-by-code) are real sign-ins. No path in U mints a frontend token without one.
- **Link code transport (C-WR-01).** The code lives only in the fragment. The admin SPA opens the link with `window.open(url, '_blank', 'noopener')`, so the Referer is the admin page and never carries the code. Browsers never put a fragment in a Referer. The page moves the code to `sessionStorage`, strips it with `replaceState`, and sends `/login?next=/link-admin` without it.
- **Ban, password reset and admin-set password (C-CR-01).** Each one revokes OAuth grants in the same transaction as the user write. The MCP verifier and the MCP refresh grant also refuse banned users and tokens issued before the cutoff, so the event is not the only protection.
- **Revocation scoping (C-IN-02).** A token-specific revoke and a client-specific revoke each end only their own sessions. Pier tells the two apart by `ClientID == ""` (F `pier/events.go:85-98`), so a `usr_` token id that equals an OAuth token id does not collide.
- **Journal (C-IN-01).** The `draft_post` ceiling is enforced by pier's `allowsPrompt`. `TestJournalPromptWrites` would catch a regression because it checks both the read-only and the read-write grant.
- **Member token scopes (E-2).** Mint, check, list and discovery work per application. A plugin cannot gain backend reach by naming a member scope `backend`, because pier grants a backend subject only on the OAuth path.
- **Organisation logo (E-1).** The admin delete path (single and bulk) goes through cabana's `deleteRecord`, which runs `FormAfterDelete` inside the delete transaction.
What remains:
1. **RC-CR-01 (critical).** A user's own password change does not end an attacker's MCP or OIDC grant if that grant's tokens were created or rotated after the current session's `iat`. An attacker controls rotation timing, so the C-CR-01 scenario ("the owner changes the password after a compromise") still leaves the attacker's agent working, including any linked-admin backend reach.
2. **RC-WR-01.** C-WR-02 is closed for issued tokens only. A consent decided after the link changed, or an MCP code issued before the change and exchanged after it, still produces a `backend` grant for an administrator the consent page never showed. The token revocation also races with a concurrent refresh rotation.
3. **RC-WR-02.** The oauthserver test for the verifier's ban refusal cannot fail. `BanUsers` also moves `tokens_valid_after`, so the cutoff check alone makes the token invalid.
## Narrative Findings (AI reviewer)
## Critical Issues
### RC-CR-01: Self-service password change keeps every OAuth/MCP grant whose tokens were created or rotated after the session's `iat`
**File:** `U controllers/api_controller.go:722-732`; `O classes/cascade.go:111-113` (`created_at < before`); `O classes/mcp_store.go:252-266` (a rotated refresh token is a new row with `created_at = now`) and `:385-391` (the new access token, the same); `O classes/mcp_verifier.go:133-144` (`issuedBeforeCutoff`); `U README.md:57`; `U access_revocation_test.go:156-170`
**Issue:** `ChangePassword` sets `tokens_valid_after = iat - 1s` and fires `FireAccessRevoked(..., before = cutoff + 1s = iat, ...)`. Here `iat` belongs to the session token that made the call. A refresh renews `iat`, so it is the time of the last refresh, not of the sign-in. The README sentence "tokens issued since that sign-in survive" is wrong on this point too.
`revokeUserGrants` revokes only access and refresh rows with `created_at < before`. The MCP refresh grant creates a new refresh row and a new access row with `created_at = now` on every rotation. The verifier's `issuedBeforeCutoff` and `refreshUserUsable` compare that same `created_at` with the cutoff. So any grant that was refreshed after the caller's `iat`:
- is not revoked by the event,
- passes the verifier, and
- passes the refresh check,
and lives on indefinitely. The client chooses when to refresh: an OAuth client may present its refresh token at any time. An attacker's agent that refreshes every minute is therefore never older than the victim's session unless that session was minted within the last minute.
The ban, the password reset and the admin-set password all pass `before = time.Now()` and are not affected. Only the self-service change uses the session-relative cutoff, although nothing in the current request needs an OAuth grant kept alive.
**Failure scenario:** An attacker learns E's password, signs in, connects an MCP agent with `read write backend` (E is linked to admin A) and lets it refresh every few minutes. E notices odd posts, opens the site (session last refreshed 20 minutes ago) and changes the password from Settings. `AccessRevokedEvent` fires with `Before = iat` (20 minutes ago). The agent's current refresh token, created 3 minutes ago, is not revoked, and the verifier accepts it because `created_at` is after the cutoff. The agent keeps calling `journal_update_post` as admin A. `TestAccessRevocation` "ChangePassword fires with the stored cutoff plus one second" pins this behaviour instead of catching it.
For context: the same session-relative cutoff lets an attacker's frontend session JWT survive if it was refreshed after the victim's `iat`. That part predates this phase, but `auth_time` now makes a better cutoff possible.
**Fix:** Revoke OAuth grants as of now on a self-service change. The current request needs no OAuth grant, so nothing has to be kept:
```go
// U controllers/api_controller.go, ChangePassword
cutoff := iat.Add(-time.Second) // keeps the calling session JWT, as today
revokedAt := time.Now()
if err := lagoon.Transaction(r.Context(), gdb, func(ctx context.Context, tx *gorm.DB) error {
// ... Updates as today ...
return classes.FireAccessRevoked(ctx, app, tx, []uint{user.ID}, revokedAt, classes.AccessRevokedPasswordChanged)
}); err != nil { ... }
```
Change the README sentence on line 57 to match. Rewrite the test to create an MCP grant, rotate it after the session's `iat`, change the password, and assert that the rotated grant is revoked and refused. Optionally, base the session cutoff on the calling token's `auth_time`, and have `/refresh` and the jwt guard compare `auth_time` with `tokens_valid_after`, so an attacker's refreshed session JWT also stops surviving.
## Warnings
### RC-WR-01: C-WR-02 is closed for issued tokens only: pending consents and unexchanged codes still bind `backend` to whichever administrator is linked later, and the revocation races with refresh rotation
**File:** `O classes/cascade.go:136-138` (with a non-empty `scope`, the function returns before touching `MCPCode` rows), `:198-216` (`OnBackendLinkChanged`), `:111-134` (plain `SELECT`, then `UPDATE ... WHERE id IN`); `O controllers/mcp_consent_api.go:82-89` (show reads the admin), `:133-144` (decide checks only `linked`, not which admin); `F pier/identity.go:225-235` (backend subject = whoever is linked at request time)
**Issue:** The fix revokes unrevoked access and refresh tokens that carry `backend` when a link is created or removed. Three paths still let a grant act as an administrator the consent page did not show:
1. **Decide after a link change.** `MCPConsentShow` shows admin X. `MCPConsentDecide` checks only that some link exists (`linked`). If the link moves to Y between show and decide (an administrator's `SetLink` after an unlink, within the pending request's lifetime), the user approves "act as X" and gets a `backend` grant that pier resolves to Y.
2. **Code issued before, exchanged after.** `revokeUserGrants` with `scope = "backend"` returns at line 136-138 and never deletes the user's unused MCP codes. A code issued while linked to X and exchanged after a relink to Y mints a fresh token (`created_at` after the revocation) with `backend`, acting as Y.
3. **Rotation race.** The revocation reads candidate rows with a plain `SELECT` under READ COMMITTED, then updates them by id. If a refresh rotation of the same grant is in flight, the `UPDATE` waits on the old row's lock. Once the rotation commits, the new refresh row (inserted after the `SELECT`'s snapshot) is never revoked and keeps `backend` across the link change.
The README now promises that "a grant never acts as an administrator the user did not consent to". These paths contradict it. Each window is short, so this is a warning.
**Fix:** Bind the grant to the consented administrator, so no revocation timing matters (this was option 2 of C-WR-02 and is additive):
- At decide, take the `backend_admin.login` (or id) the page showed in the body and refuse with 409 when the current link differs.
- Store `backend_user_id` on the MCP code, access and refresh rows. Copy it on code exchange and rotation, expose it in `pact.MCPGrant`, and have pier drop the backend subject when the current link's admin id differs.
As a cheaper stopgap: in `OnBackendLinkChanged` also delete the user's MCP codes with `used_at IS NULL` whose scopes contain `backend`, and lock the user's refresh rows (`SELECT ... FOR UPDATE` on `user_id`) before collecting ids, so a concurrent rotation is serialized behind the revocation.
### RC-WR-02: The verifier's ban refusal is untested; the "banned user is refused" test passes even with the ban check removed
**File:** `O mcp_revocation_test.go:138-160`; code under test `O classes/mcp_verifier.go:96-105`, `O classes/mcp_store.go:299-313` (`refreshUserUsable` ban branch)
**Issue:** The subtest bans with `userclasses.BanUsers`, which calls `RevokeUserAccess` and stamps `tokens_valid_after = now`. The grant was created before that, so the `issuedBeforeCutoff` check already returns `ErrMCPTokenInvalid` and the refresh is already refused. Deleting the `ThrottleStates` and `banned[...]` blocks from both the verifier and `refreshUserUsable` leaves the test green.
The ban check is the only protection when a ban is written without `RevokeUserAccess`, which the user README explicitly allows applications to do ("after writing a ban directly to `user_throttle`"). The refresh path's pre-cutoff branch has no isolated rotation test either. The suite covers rotation only for a deleted user, while the doc comment of `TestMCPVerifierRevocation` claims "the refresh rotation refuses a banned, a pre-cutoff and a deleted user". The demo's e2e step (a) cannot tell the three mechanisms apart either.
**Fix:** In the ban subtest, write the ban without moving the cutoff, then assert both refusals:
```go
if err := env.gdb.Exec(`INSERT INTO user_throttle (user_id, is_banned) VALUES (?, TRUE)`, user.ID).Error; err != nil {
t.Fatal(err)
}
invalid(t, "banned (no cutoff moved)", grant)
status, out := env.refreshStatus(t, grant)
refused(t, "banned (no cutoff moved)", status, out)
```
Add a rotation subtest for a pre-cutoff refresh token (`setGrantCreated` before `setCutoff`, then `refreshStatus` must be `invalid_grant`). The "same second" subtest only partly covers this.
## Info
### RC-IN-01: The server still accepts the link code in URLs
**File:** `U routes.go:46-48`; `U controllers/backend_link_controller.go:36-54, 111-117`; `U controllers/api_controller.go:1166-1189` (`readFields`); `D deploy/nginx/demo.summercms.io.conf:96-103`
**Issue:** The shipped page never puts the code in a URL. The server, though, still serves the deprecated `GET /backend-link/{code}`, kept on purpose for compatibility. In addition, `readFields` falls back to `r.ParseForm()` for any non-JSON body, and `r.Form` includes the query string. So `POST /backend-link/show?code=…` and `POST /backend-link/confirm?code=…` both work, and nginx's default access log records those request lines. A third-party client of this core plugin can reintroduce the leak without noticing.
**Fix:** In `BackendLinkShowPost` and `BackendLinkConfirm`, read the code only from a JSON body (ignore `r.URL.Query()`). Consider `access_log off;` (or a `log_format` without `$request_uri`) for `location ~ ^/_user/api/v1/backend-link/` until the GET route is removed in a major release.
### RC-IN-02: The `auth_time` proof has a one-second blind spot
**File:** `U classes/backend_links.go:276`
**Issue:** `auth_time` is truncated to whole seconds and compared with `created_at` truncated to the same second. A sign-in that happened earlier in the same second as the request, but before it, counts as fresh. The design accepts this, and it is not exploitable in practice. It is worth stating in the doc comment next to the D-05 claim.
**Fix:** Document it, or compare `auth_time >= created_at.Truncate(time.Second) + 1s` and accept that a sign-in in the request's own second needs one more sign-in.
### RC-IN-03: The logo URL fallback ignores the configured public path
**File:** `U classes/organisation_logo.go:57-61` (copied from `U classes/avatar.go:52-56`)
**Issue:** When `Thumb` fails, the fallback is `"/" + attach.BlobKey(f.DiskName)`. That skips `storage.uploads.public_path_prefix` and `public_url` (`F lagoon/attach/thumb.go:85-104`). With the WinterCMS layout (`/storage/app/uploads/public`) the fallback URL is a 404.
**Fix:** `return f.URL(), true, nil` (and the same in `AvatarURL`).
### RC-IN-04: nginx's `/mcp` cap now equals pier's, so oversized bodies still get nginx's HTML 413
**File:** `D deploy/nginx/demo.summercms.io.conf:217`; `F pier/pier.go:38`
**Issue:** Both caps are 4 MiB. Bodies between 2 and 4 MiB now reach pier (C-IN-03 is fixed). A body over 4 MiB is still refused by nginx with an HTML 413, so the JSON-RPC error the new comment describes is never seen through nginx.
**Fix:** Set `client_max_body_size 5m;` (and update the `check-deploy.sh` string), so pier's own refusal is the one clients get.
### RC-IN-05: Test gaps and tests that pass without running
**File:** `U organisation_logo_test.go:138-142`; `V app/lib/linkCode.test.ts:13-21`; the new `U` Postgres tests (`backend_link_auth_time_test.go:25-27`, `backend_link_show_test.go:24-26`, `access_revocation_test.go:113-115`, `member_token_scopes_test.go:64-66`, `organisation_logo_test.go:58-60`, `api_token_events_test.go:17-19`, `mcp_guard_client_ip_test.go:17-19`), `O mcp_revocation_test.go:65-67, 184-186`, `D mcp_revocation_e2e_test.go:247-249`
**Issue:**
- The organisation delete test checks only that the `system_files` rows are gone. The after-commit `attach.DeleteKeys` part of `FormAfterDelete` (the blobs and thumbnails) is never asserted, so a broken bucket hand-off would pass.
- In `linkCode.test.ts`, the case `#next=/account&code=${code}x` sits under "foreign", but it returns `''` because of the trailing `x`. The behaviour the plan asked about (a valid code followed by other parameters) is not pinned at all.
- The Postgres tests use `if testing.Short() { return }` instead of `t.Skip`, so `-short` runs report them as passed rather than skipped.
**Fix:** After the delete, wait for the commit and assert that `bucket.Exists(ctx, attach.BlobKey(logoDisk))` is false. Add `expect(readLinkCode('#code=' + code + '&next=/x')).toBe(code)`. Use `t.Skip("Postgres")`.
### RC-IN-06: The legacy `MintAPIToken` fallback is process-global and is widened even when boot fails
**File:** `U classes/api_token_scopes.go:125, 144-166`; `U plugin.go:127-146`
**Issue:** `CompileAPITokenScopes` adds every declared name to the process-wide `declaredScopes` set before `bootAPITokenScopes` checks the phrase keys. A boot that then fails on a missing key has already widened what `MintAPIToken` accepts. In a process with several applications (tests, or a future multi-app host), `MintAPIToken` also accepts another application's scopes. Both are documented, and production runs one app per process.
**Fix:** Add the names only after the phrase check passes (return the declared list from `CompileAPITokenScopes` and add it in `bootAPITokenScopes` just before `app.Publish`). Mark `MintAPIToken` as `Deprecated:` in favour of `MintAPITokenFor`.
### RC-IN-07: sm-oauthserver-plugin builds only through its workspace file
**File:** `O go.mod:6` (`sm-user-plugin v0.1.10`); `O go.work` (tracked, `replace ... => ../sm-demo-app/plugins/golem15/user`)
**Issue:** O now uses `userclasses.AccessRevokedEvent`, `FireSubjectEvent` and `FireSubjectEventAfterCommit`, which v0.1.10 does not have. `GOWORK=off go build ./...` fails with `undefined: userclasses.AccessRevokedEvent`. The planned release order (tag U, then bump O) covers this, but nothing enforces it.
**Fix:** Make the O release checklist require bumping `sm-user-plugin` to the new U tag and running a `GOWORK=off go build ./...` gate before tagging O.
## Ruling: `readLinkCode` with other fragment parameters
`readLinkCode('#code=<64 hex>&next=/x')` returns the code. **This is not a defect.** `URLSearchParams.get('code')` returns the first `code` parameter, and the value still has to match `^[a-f0-9]{64}$` exactly, so nothing beyond a well-formed code is ever accepted. Other parameters are ignored, the fragment is stripped right away by `replaceState` (`V link-admin.vue:55`), and the code is only ever sent in a JSON body. Accepting extra parameters is the more robust behaviour: an analytics tool or a redirect that appends a fragment parameter does not break the link. The plan's expectation of an empty result was wrong. The one gap is that no test pins the behaviour (RC-IN-05).
## Earlier findings
| ID | Verdict | Evidence |
|----|---------|----------|
| C-CR-01 (ban / password reset keep MCP grants) | partially fixed | Fixed for ban (`U controllers/users_admin_controller.go:651-683`), reset (`U controllers/api_controller.go:454-466`) and admin-set password (`U controllers/users_admin_controller.go:247-255`). The verifier refuses banned users and pre-cutoff tokens (`O classes/mcp_verifier.go:96-105`), and so does the refresh grant (`O classes/mcp_store.go:281-313`). `LinkedBackendUser` excludes banned users (`U classes/backend_links.go:437-443`). Not fixed for the self-service password change: `U controllers/api_controller.go:722-732` revokes only grants older than the session's `iat` (RC-CR-01). |
| C-CR-02 (fresh-sign-in proof defeated by refresh) | fixed | `U controllers/backend_link_controller.go:102-110` reads `auth_time` again from the accepted token and checks the subject; `U classes/backend_links.go:276` compares it with `created_at`; `F bouncer/refresh.go:141-142` carries `auth_time` forward; the page shows the signed-in email with "Not you?" (`V link-admin.vue:150-155`). One-second blind spot only (RC-IN-02). |
| C-WR-01 (link code in logged URLs) | fixed | URL is `…#code=` (`U account_contributions.go:120`); the page reads the hash, stores the code in `sessionStorage` and strips it (`V link-admin.vue:50-60`); show is a POST with a JSON body (`V link-admin.vue:89`, `U routes.go:48`); the sign-in link carries no code (`V link-admin.vue:24`). The server still accepts the code in URLs (deprecated GET route, query fallback in `readFields`): RC-IN-01. |
| C-WR-02 (`backend` grant re-targeted by a later link) | partially fixed | Issued access and refresh tokens carrying `backend` are revoked on link and unlink (`O classes/cascade.go:173-216`, `O plugin.go:103-105`). Pending consents, unexchanged MCP codes and a refresh rotation racing the revocation still bind `backend` to a later admin (RC-WR-01). |
| C-IN-01 (draft_post for read-only grants) | fixed | `J mcp.go:160-162` `Writes: true`; enforced by `F pier/filter.go:52`; `J mcp_prompt_writes_test.go` checks both grants. |
| C-IN-02 (one revoke ends every MCP session) | fixed | `U controllers/api_tokens.go:142` (TokenID); `O controllers/authorizations.go:122-126` (ClientID); `F pier/events.go:85-98` tells the two apart. |
| C-IN-03 (nginx 2 MiB < pier 4 MiB) | fixed | `D deploy/nginx/demo.summercms.io.conf:217`, gated by `D scripts/check-deploy.sh:254-256`. Over-cap bodies still get the HTML 413 (RC-IN-04). |
| C-IN-04 (no `last_used_ip` on MCP personal tokens) | fixed | `U classes/mcp_guard.go:172-176`; pier sets the client IP (`F pier/pier.go:254`). |
| C-IN-05 (dead and duplicated code) | fixed | `requestUsable` calls `IsUsableAt` (`U classes/backend_links.go:227-235`); `BackendLinksFor` is used by both callers (`U classes/backend_links.go:57-64`, `U account_contributions.go:57-59`, `U controllers/backend_link_controller.go:16-18`); O's subject helpers delegate to U (`O classes/subject_events.go:18-26`). |
| C-IN-06 (hard-coded link page path) | fixed | `golem15.user.backend_links.page_path` (`U classes/backend_links.go:85-104`, `U config/config.yaml:48-52`), tested in `U backend_link_show_test.go:94-109`. |
---
_Reviewed: 2026-10-23T17:19:01Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_

View File

@@ -0,0 +1,590 @@
---
phase: 19.7-generic-mcp-module
reviewed: 2026-10-23
depth: deep
lanes: [A framework MCP core, B cabana and admin SPA, C plugins and demo]
files_reviewed: 128
findings:
critical: 3
warning: 17
info: 20
total: 40
status: issues_found
---
# Phase 19.7 code review
Three deep review lanes run in parallel over the phase's changes (framework since 1bd91a4^, sm-user-plugin since 3828a65, sm-oauthserver-plugin since d31f8e7, sm-journal-plugin since ffa2c8f, sm-demo-app since a25444e^, vue-demo-app since f397df2). Finding ids carry the lane prefix (A-, B-, C-). Each lane's file list is in its section's source report.
| Lane | Critical | Warning | Info |
|------|----------|---------|------|
| A framework MCP core | 1 | 9 | 8 |
| B cabana and admin SPA | 0 | 6 | 6 |
| C plugins and demo | 2 | 2 | 6 |
# Lane A
## Phase 19.7: Code Review Report (lane A, framework MCP core)
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 32
**Status:** issues_found
### Summary
I reviewed pier (identity, catalog, dispatch, stateful session router, stateless path, stores, fan-out), the surf glue (`pier_on.go`, `MountStream`, `Router.Handler`/`compileHandlers`), the bouncer dispatch marker and registry, the pact MCP contracts, wristband's scope allow-list and cooler's `KeyPrefix`. I traced the call chains into the SDK (go-sdk v1.8.0 `streamable.go` and `server.go`), cabana (`ResolveBackend`, `ResolveBackendToken`, `ScopeLimitFor`, `Allows`, `refuseFrontendMarker`, `BackendUsers.FindByID`) and the user plugin's personal-token resolver.
The core identity model holds up. Every request resolves its own identity, the receiving middleware re-filters list and call for that identity, the binding check applies to POST, GET and DELETE, the marker stays in the context only, and the backend provider's per-method scope limit matches cabana's HTTP guard (a `methodOK == 0` token gets an empty limit, which `Allows` refuses). I found no identity or guard bypass.
The defects are in lifecycle and edge handling:
- Stateful local sessions are never reaped, so every abandoned session leaks an SDK server and its goroutines until the process restarts.
- Dispatch hands routes path values that the HTTP mux can never produce (`..`, traversal inside `{x...}`, an empty `{id}`).
- The per-binding cap has a TOCTOU race.
- A session's or stateless server's tool set never widens when the same binding comes back with more scope.
- On Redis, an instance that serves only stateless clients never subscribes to fan-out events, and stateless listen streams are never re-verified.
- Global mutexes are held across guard and database I/O.
- Raw database errors reach the MCP client.
I did not re-report items that are accepted or deferred in 19.7-SECURITY-REVIEW.md or deferred-items.md: superuser reach, descriptions, the `initialized` notification landing on another instance, the protocol-version context key, the MCP grant revoke list, the duplicate `*pier.Pier` publish and the 5 s link cache.
### Critical Issues
#### A-CR-01: Stateful local sessions are never expired or reaped (unbounded server and goroutine leak)
**File:** `modules/pier/session.go:50`, `:143-149`, `:289-315`; `modules/pier/store_memory.go:27-34`; `modules/pier/audit.go:67`
**Issue:** Every session opened through `serveNew` (and every copy rehydrated in `lookup`) is stored in `sr.local` with a live `*mcp.Server`, a `StreamableServerTransport`, a connected `*mcp.ServerSession` (jsonrpc2 reader goroutines) and the `ss.Wait()` goroutine started in `connect`. A local copy is removed in only three cases: a DELETE, a revoked or deleted subject event, or a later request for the same id whose record has gone (`lookup` → `closeLocal`). pier serves the transport directly, so it bypasses `StreamableHTTPHandler`. In go-sdk v1.8.0 the idle close (`StreamableHTTPOptions.SessionTimeout`, `streamable.go:721`) exists only in that handler; the transport has no idle close of its own. The store forgets idle sessions (memory sweep, Redis PX TTL) but nothing closes the local copy. The documented `expired` log step (`audit.go:67`) is never emitted anywhere.
**Failure scenario:** Most MCP clients exit without sending DELETE (a desktop client closed, a CLI agent run finishing). Each such session stays in memory with its goroutines for the life of the process. Once the record expires, `Count` drops, so the same binding can open `max_per_binding` new sessions every `idle_ttl`. One token can therefore grow memory and goroutines without bound (about 20 sessions per 30 min per binding with the defaults, multiplied by bindings), up to an OOM. The leaked sessions also stay in `sessionRouter.matching`, so every permission or link event recomputes `allowed(...)` (database-backed guard checks) for each dead session.
**Fix:** Reap local copies whose record has expired. One option is a janitor at the store's TTL granularity:
```go
// in Mount, after p.sessions is built
go p.sessions.reap(ctx, p.cfg.IdleTTL/2)
func (sr *sessionRouter) reap(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sr.mu.Lock()
ids := make([]string, 0, len(sr.local))
for id := range sr.local {
ids = append(ids, id)
}
sr.mu.Unlock()
for _, id := range ids {
if _, ok, err := sr.p.store.Get(ctx, id); err == nil && !ok {
sr.closeLocal(id)
logSession(logger(), "expired", id, "")
}
}
}
}
}
```
A simpler alternative is a per-`localSession` `time.AfterFunc(IdleTTL)` that is reset in `lookup` and closes the session, mirroring the SDK's `sessionInfo.timer`. Add a test that a session idle past `idle_ttl` disappears from `sr.local` and its `ss.Wait()` returns.
### Warnings
#### A-WR-01: Dispatched path values bypass the mux's path cleaning (`..` and traversal inside `{x...}`)
**File:** `modules/pier/dispatch.go:96-104`, `:117-124`, `:181-183`
**Issue:** A single-segment value is refused only when it contains `/`. A wildcard (`{p...}`) value is split on `/` and each part is `url.PathEscape`d, which leaves `.` and `..` unescaped. `req.SetPathValue` then hands the raw value to the compiled chain. Over HTTP, `net/http.ServeMux` cleans the path and redirects before any handler runs. I checked this: `GET /files/a/../../etc/passwd` → 307 `/etc/passwd`, and `GET /items/..` → 307 `/`. A route's `{id}` is therefore never `..` or `.`, and a `{p...}` never contains `..` segments or empty segments. `Router.Handler` serves the route's chain directly (no mux), so pier delivers values the route has never seen over HTTP. `T-19.7-03` states that the in-process chain runs "the same constraints … as an HTTP request", but the mux's cleaning invariant is not part of that chain.
**Failure scenario:** A plugin declares a tool on `GET /_media/api/files/{path...}` whose handler does `os.Open(filepath.Join(root, r.PathValue("path")))`. That code is safe over HTTP because the mux cleans the path. An agent calls the tool with `{"path":"a/../../../etc/passwd"}`: `filepath.Join` resolves outside `root` and the route serves the file. For a single segment, `{"id":".."}` reaches a route that over HTTP could only see a real id.
**Fix:** Refuse what the mux would never deliver:
```go
case argPath:
s, err := scalarString(v)
...
wild := strings.Contains(t.pattern, "{"+f.key+"...}")
for _, seg := range strings.Split(s, "/") {
if seg == "" || seg == "." || seg == ".." {
return toolError(fmt.Sprintf("pier: invalid arguments: %q must not contain empty, . or .. segments", f.name)), 0
}
}
if !wild && strings.Contains(s, "/") { ... }
```
Alternatively, require `path.Clean("/"+s) == "/"+s`. Add a `TestDispatch` case for `..` and `a/../b`.
#### A-WR-02: An optional path field leaves `{key}` literal in the target and an empty path value
**File:** `modules/pier/catalog.go:213-235`; `modules/pier/dispatch.go:90-93`, `:117-124`, `:181-183`
**Issue:** `compileTool` checks that every `{segment}` has a `path:` field, but not that the field is required. jsonschema-go v0.4.3 (`infer.go:342`) makes a field optional when it carries `omitempty` or `omitzero`. If the caller omits that field, `run` skips it (`if !ok { continue }`), the target keeps the literal `{id}` (`httptest.NewRequest` accepts it), and `SetPathValue(seg, pathValues[seg])` sets `""`. Over HTTP a `{id}` segment never matches an empty segment.
**Failure scenario:** A plugin writes `ID string \`json:"id,omitempty" path:"id"\`` on `DELETE /api/items/{id}`. The agent omits `id`, and the route runs with `PathValue("id") == ""` and `URL.Path == "/api/items/{id}"`. A route that queries `WHERE slug = ?` with `""`, or that treats an empty id as "all", acts on data that no HTTP request could reach.
**Fix:** At boot, refuse a path field that the schema does not list as required:
```go
for _, f := range fields {
if f.kind == argPath && !slices.Contains(schema.Required, f.name) {
return nil, fmt.Errorf("%s: path field %q must be required (drop omitempty/omitzero)", errPrefix, f.name)
}
}
```
Also refuse a missing segment value at runtime before building the request (defence in depth).
#### A-WR-03: `max_per_binding` is check-then-act (TOCTOU) and can be exceeded by concurrent initializes
**File:** `modules/pier/session.go:134-159`; `modules/pier/store_redis.go:41-52`, `:119-137`
**Issue:** `serveNew` calls `store.Count`, then `connect`, then `store.Put`, with no reservation in between. N concurrent `initialize` POSTs from one binding all read `Count == k < max` and all `Put`. The Redis path is non-atomic as well (`SMEMBERS` plus `EXISTS` per member, then a separate `MULTI`).
**Failure scenario:** A client (or one stolen token) sends 100 parallel initializes. All pass the cap of 20 and 100 sessions open, each with a local server (see A-CR-01). The cap is the per-binding control cited for T-19.7-18, but it only holds for serial requests. The inline throttle (120/min per IP) limits the rate, not concurrency.
**Fix:** Make the reservation atomic in the store, for example `Reserve(ctx, binding, id, max, ttl) (bool, error)`. On Redis, use a Lua script: prune dead members, `SCARD`, and if below max `SADD` plus `SET` of a placeholder record. On memory, do the same under `m.mu`. Call it in place of `Count`/`Put`, and release on connect or initialize failure.
#### A-WR-04: A session's or stateless server's tool set never widens when the same binding returns with more scope
**File:** `modules/pier/stateless.go:87-93`; `modules/pier/session.go:276-278`, `:463-487`, `:516-530`
**Issue:** `newServer` registers only the tools that `allowed(...)` grants the identity that built the server. On a later request with the same binding, `getServer` (stateless hit) and `lookup` (stateful) only overwrite `identity`. The server's tool set is recomputed only by a fan-out event (`handleEvent`). The receiving middleware can only filter `tools/list` down (`kept` is a subset of what the server holds). A tool that the new identity may call, but that the server never registered, is missing from the list, and `tools/call` passes pier's check and then fails in the SDK as an unknown tool.
**Failure scenario:** An OAuth client first connects with `scope=read` (binding `oauth:<client>:<user>`). The stateless LRU builds the server without write tools. The user re-authorizes with `read write`; the binding is the same. Every following request hits the cached entry, which `MoveToFront` keeps alive, so the write tools never appear and calls to them fail until LRU eviction or an unrelated subject event. The same happens to a stateful session whose client refreshes into a wider grant, and when `backend` is added to a grant.
**Fix:** On a hit, compare a fingerprint of the authorization-relevant parts of the identity (sorted scopes, frontend and backend subject ids, ceiling). When it changed, run the same diff as for an event:
```go
if e.fingerprint != fp(id) {
tools, prompts := sp.p.catalog.allowed(r.Context(), id)
applyDiff(e.server, sp.p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = fp(id)
}
```
Do the same in `lookup` under `ls.mu`. An alternative for the stateless path is to key the LRU by binding plus fingerprint.
#### A-WR-05: With the Redis store, an instance that serves only stateless clients never subscribes to fan-out events
**File:** `modules/pier/pier.go:140-167`; `modules/pier/store_redis.go:189-204`; `modules/pier/stateless.go:74-76`, `:146-174`; `modules/pier/events.go:13-27`
**Issue:** The Redis store, and with it the `mcp:events` subscription (`rs.Subscribe` inside `open`), is created lazily by the first store call. Only the stateful router (`Count`/`Put`/`Get`/`Touch`/`Delete`) and a locally fired subject event (`listenEvents` → `Publish`) make that call. The stateless path (`route` → `statelessPath.ServeHTTP` → SDK) never touches the store.
**Failure scenario:** In a two-instance Redis deployment, instance B receives only 2026-07-28 clients (or only `server/discover` traffic). An admin revokes a grant on instance A. A publishes `revoked`, but B never subscribed: its LRU entries for that subject are not evicted, their `subscriptions/listen` streams are not closed (see A-WR-06), and permission or link changes never send `list_changed` on B. Per-request bearer checks still refuse calls, but D-26 and D-27 fan-out is broken on B.
**Fix:** Open the store and subscribe when the first request arrives on any path, for example `if ls, ok := p.store.(*lazyStore); ok { _, _ = ls.get(r.Context()) }` at the top of `route`. Better, start a background goroutine at mount that retries `open` with backoff, so the subscription exists before traffic arrives. Keep the 503 behaviour for stateful requests when the store is down.
#### A-WR-06: Stateless `subscriptions/listen` streams are never re-verified, and LRU eviction orphans them from revocation
**File:** `modules/pier/stateless.go:98-106`, `:128-140`; `modules/pier/session.go:369-392`
**Issue:** The stateful GET stream re-resolves its bearer every `streamRecheckInterval` (Pitfall 10). The stateless path has no equivalent. In go-sdk v1.8.0 a 2026-07-28 `subscriptions/listen` is a long-lived POST (`shouldPropagateCancellation` with `isSubscriptionsListen`), so the only thing that ends it is `evict` on a `revoked` or `deleted` event. In addition, when `getServer` drops the oldest entry at capacity (`sp.order.Remove(last); delete(sp.entries, ...)`), it does not close that server's sessions. A later `evict` for that subject can then no longer find the entry.
**Failure scenario:** A token expires (no event is fired), or a revocation event is missed (A-WR-05, or a go-redis pub/sub drop), or the identity's entry was LRU-evicted before the revocation. The client's listen stream stays open indefinitely and keeps receiving list-change notifications for an identity that no longer authenticates, which the stateful path explicitly prevents.
**Fix:** When evicting for capacity, close the evicted server's sessions, as `evict` does:
```go
old := last.Value.(*statelessEntry)
sp.order.Remove(last)
delete(sp.entries, old.binding)
go func() { for ss := range old.server.Sessions() { _ = ss.Close() } }()
```
Also wrap the stateless handler with the same periodic bearer re-check that `serveStream` uses when the request body is a `subscriptions/listen` (`peekMethods` is already available).
#### A-WR-07: Global mutexes are held across guard and database I/O, so one slow check stalls every session on the instance
**File:** `modules/pier/session.go:144-149`, `:247-275`; `modules/pier/stateless.go:85-107`
**Issue:** `serveNew` and `lookup` hold `sr.mu` (the router-wide lock) while `connect` → `newServer` → `catalog.allowed` runs `MCPAllows` or `MCPAllowsMethod` for every catalog item. For the backend provider each call is a `cabana.BackendPrincipal` database query, and the user plugin's guard also queries. `getServer` holds `sp.mu` over the same work. `matching` (event fan-out) and every `lookup` need the same locks.
**Failure scenario:** The database slows down (lock contention, pool exhaustion) during one client's initialize or rehydration. Every other MCP request on the instance (POSTs to existing local sessions, GET streams being established, DELETEs, subject events) blocks on `sr.mu` or `sp.mu` until that request's context ends. One client's slow guard check becomes an instance-wide stall of the endpoint.
**Fix:** Build the server outside the lock and insert it under the lock with a re-check:
```go
ls, err := sr.connect(ctx, sid, id, state) // no lock held
sr.mu.Lock()
if cur := sr.local[sid]; cur != nil { sr.mu.Unlock(); _ = ls.ss.Close(); ls = cur } else { sr.local[sid] = ls; sr.mu.Unlock() }
```
Use the same pattern for `getServer` (build on miss without `sp.mu`, then insert or adopt the existing entry).
#### A-WR-08: Raw provider and database errors are returned to the MCP client as tool text
**File:** `modules/pier/dispatch.go:147-150`; `modules/cabana/auth.go:44-50` (callee)
**Issue:** `MCPDispatch` errors become `toolError("pier: " + err.Error())`. For the built-in backend provider the error comes from `cabana.BackendPrincipal` → `BackendUsers.FindByID`, which returns the raw gorm or pgx error. Plugin providers (`MCPDispatch` in the user guard) also return raw database errors. pgx error strings include the server address, the database and user names, and SQL state details.
**Failure scenario:** The database fails between the listing check (which only logs its error) and the dispatch, or a provider's `MCPDispatch` queries while its `MCPAllows` does not. The agent and its transcript then receive text such as `pier: failed to connect to user=summer database=summer: 10.x.x.x:5432 ... dial error`, which leaks infrastructure details to an outside client.
**Fix:** Log the error server-side and return a fixed text:
```go
if err != nil {
logger().Warn("pier: guard dispatch failed", "tool", t.name, "guard", t.guardName, "error", err)
return toolError("pier: the tool could not be run for this caller"), 0
}
```
Keep the specific "unknown or deactivated" message only for the provider's own sentinel errors.
#### A-WR-09: A personal-token resolver that leaves `TokenID` zero puts every user on one binding
**File:** `modules/pier/identity.go:229-245`; `modules/pact/mcp.go:237-244`
**Issue:** The binding of a frontend personal token is `"pat:user:" + subject.TokenID`. `resolvePersonalToken` checks `UserID != 0` but not `TokenID`, and the `MCPPersonalTokenResolver` contract never says `TokenID` is required. The shipped user plugin sets it, but any other resolver that omits it maps every token of every user to `pat:user:0`.
**Failure scenario:** With a third-party resolver and `mcp.allow_personal_tokens: true`, all personal-token users share one binding. The 20-session `max_per_binding` cap becomes global, so one user exhausts it for everyone (429). User B can DELETE user A's session (the binding check passes) when B learns the id. The stateless LRU serves one shared server whose `identity` (used for fan-out matching) flips between users.
**Fix:** Refuse a subject without a token id, or include the user id in the binding:
```go
if err != nil || !ok || subject.UserID == 0 || subject.TokenID == 0 {
return Identity{}, ErrUnauthorized
}
```
Document in `pact.MCPPersonalTokenResolver` that `TokenID` must identify the token row.
### Info
#### A-IN-01: The GET stream re-check ignores a session whose record is gone
**File:** `modules/pier/session.go:381-387`; `modules/pier/store_redis.go:91-101`; `modules/pier/store_memory.go:68-79`
**Issue:** Every minute the recheck calls `store.Touch` and ignores the result. `Touch` on a missing record returns `nil` in both stores. If the `ended` pub/sub event for a DELETE served on another instance is lost, the local GET stream outlives the ended session for as long as the bearer resolves.
**Fix:** In the ticker branch, `if _, ok, err := sr.p.store.Get(ctx, ls.id); err == nil && !ok { cancel(); sr.closeLocal(ls.id); return }`.
#### A-IN-02: `KeyPrefix`'s doc comment swallowed `keyPrefix`'s
**File:** `modules/cooler/cooler.go:353-366`
**Issue:** The new comment was inserted directly under `keyPrefix`'s existing comment block. `go doc cooler.KeyPrefix` now starts with "keyPrefix returns …", and `keyPrefix` has no doc.
**Fix:** Move the original five lines down to sit directly above `func keyPrefix`.
#### A-IN-03: The `MCPPersonalTokenResolver` doc describes a resolver chain that pier does not have
**File:** `modules/pact/mcp.go:237-244`; `modules/pier/identity.go:224-232`
**Issue:** The doc says "the boolean is false when the token's prefix is not the resolver's, so the next resolver is tried", but pier looks up one published resolver (`app.Lookup`) and treats `ok == false` as unauthorized. A second plugin cannot publish another one (duplicate provider).
**Fix:** Reword the doc (one resolver per application; false means "not mine, unauthorized"), or support a registry like `MCPGuards`.
#### A-IN-04: Integer path and query arguments above 2^53 lose precision
**File:** `modules/pier/dispatch.go:256-271`, `:301-311`
**Issue:** `scalarString` and `queryStrings` decode into `any`, which gives `float64`. An id of `9007199254740993` is forwarded as `9007199254740992`, so the call addresses a different record.
**Fix:** Decode with `json.NewDecoder(bytes.NewReader(raw)).UseNumber()` and render `json.Number` as its string.
#### A-IN-05: The dispatched request carries the `httptest` Host `example.com` and no scheme
**File:** `modules/pier/dispatch.go:161-180`
**Issue:** `httptest.NewRequestWithContext` sets `Host: example.com` and no TLS. pier copies the client address, X-Forwarded-For, X-Real-IP and Accept-Language, but not Host, X-Forwarded-Proto or X-Forwarded-Host. A route that builds absolute URLs from the request, or compares Host, behaves differently than over HTTP (wrong links in tool results).
**Fix:** Copy the outer `r.Host` into `callInfo` and set `req.Host`. Copy `X-Forwarded-Proto` and `X-Forwarded-Host` like the other forwarding headers.
#### A-IN-06: Audit and lifecycle labels do not match what happens
**File:** `modules/pier/audit.go:67`; `modules/pier/session.go:171-192`, `:572-575`
**Issue:** The `expired` session step is documented but never logged (see A-CR-01). Tool calls served by `serveEphemeral` (stateful path, no session id) are logged with session `stateless`.
**Fix:** Emit `expired` from the reaper, and log ephemeral calls with a distinct label (for example `ephemeral`).
#### A-IN-07: Embedded-struct fields on a GET or DELETE tool are dropped silently
**File:** `modules/pier/catalog.go:236-242`, `:380-385`; `modules/pier/dispatch.go:129-136`
**Issue:** `inputFields` skips anonymous fields without tags because "their promoted fields are body fields". The GET/DELETE body check only looks at `fields`, so an embedded struct on a GET tool passes boot. Its promoted properties are in the schema and validated, then never sent, because a GET has no body.
**Fix:** In `inputFields`, recurse into embedded structs (classifying the promoted fields), or refuse an embedded field on GET/DELETE tools at boot.
#### A-IN-08: pier does not constrain the marker's guard list to the tool's guard
**File:** `modules/pier/dispatch.go:151-159`
**Issue:** `disp.Guards` comes from the provider and is copied into the marker as is. A frontend provider that answers `Guards: ["backend"]` (a provider bug) would have its principal honoured by every `backend`-guarded middleware except cabana's (which checks `Principal.Backend` since c49983d). pier is the only dispatcher, so it is the cheapest place to enforce the invariant.
**Fix:** Refuse the call if `t.guardName != BackendGuard && slices.Contains(disp.Guards, BackendGuard)`, or if `principal.Backend != (t.guardName == BackendGuard)`.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane B
## Phase 19.7 (lane B): Code Review Report
**Reviewed:** 2026-10-23
**Depth:** deep
**Files Reviewed:** 37
**Status:** issues_found
### Summary
Lane B covers the cabana seams (dispatch marker in `ResolveBackend` and the `backend` middleware, `ResolveBackendToken` / `ScopeLimitFor` / `BackendPrincipal`, My account contributions, prompted record actions, subject events, OpenAPI) and the admin SPA side (ConfirmDialog prompt, RecordActions, AccountContributions, plus the keyboard-shortcut and remount work committed in the same range).
What I traced and found correct:
- **Marker:** `refuseFrontendMarker` wraps the guard middleware that surf registers as `backend` (surf/router.go:685, `admin.Middleware`), so plugin routes are covered as well as the admin API. `ResolveBackend` applies the same Backend and scope checks.
- **Subject events:** `fireSubjectChanged` runs after commit on every path that fires it. Save, Delete, RecordAction and BulkAction all go through `CRUDService.transaction`, which wraps `lagoon.Transaction`. On those paths the ctx carries the after-commit buffer, so a rollback fires nothing. Outside a transaction (token revoke) the event fires at once. The prior-state snapshots are taken at the right moment: `is_superuser` comes from a virtual field set in `FormBeforeUpdate` after the snapshot, and permissions/role are applied after `FormBeforeUpdate`.
- **Prompt validation:** the value is trimmed, must be non-empty and is capped in runes. A payload on a non-prompted action is still refused. The value reaches `Run` unchanged through the Administrators `guardOutranked` wrapper.
- **Account contributions:** the action route is protected by `interactiveOnly`, `requireAjax` and `allowAction`, with `Applies` checked again inside the transaction. `pier`'s per-request bearer re-verification makes a missed event only a stale tool list, never a reopened session.
- **Response shapes:** the new `prompt` key on `RecordAction` is `omitempty`, so it only adds data.
- **Vue:** nothing uses `v-html`, and every i18n key used exists in both `en` and `pl` phrasebooks.
No BLOCKERs. The defects below are:
- two keyboard-shortcut bugs that lose user input or navigate away unexpectedly;
- an unvalidated URL rendered into `href`;
- an over-broad session kill on personal-token revoke;
- a stale API contract;
- an all-or-nothing contributions list that fails silently.
### Warnings
#### B-WR-01: Plain-key shortcuts fire while typing inside a plugin field type's shadow DOM ("?" is swallowed, Esc leaves the form)
**File:** `admin/src/app/hotkeys.ts:208-236` (check at `:213`, helper `:100-105`)
**Issue:** `onPlainKey` is a `window` listener and decides "is the user typing?" with `isEditableTarget(event.target)`. Keyboard events are composed, so a keydown that starts in an `<input>` inside a shadow root reaches `window` with `event.target` retargeted to the shadow host (the plugin's custom element). `host.closest('input, textarea, select, [contenteditable]')` is null, so the check says "not editable".
The framework explicitly supports shadow DOM in plugin field types and widgets. `modules/cabana/README.md:185` says "any widget shadow DOM may read only these public variables", and the 10.1 research's reference element calls `attachShadow`.
Failure scenario (plugin field type with a shadow-root input on a FormView):
- Typing `?` opens the shortcut help and calls `preventDefault()`, so the character is never inserted. The admin cannot type a question mark in that field.
- Pressing Esc (for example to dismiss the plugin's own autocomplete) clicks the form's Cancel (`hotkey="escape"` in FormView.vue). A clean form navigates away immediately.
- `/` focuses the list search when the page has one.
**Fix:** test the real origin of the event:
```ts
function originOf(event: Event): EventTarget | null {
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
return path[0] ?? event.target
}
// in onPlainKey
if (event.defaultPrevented || isEditableTarget(originOf(event))) {
return
}
```
Also treat any custom element (a tag name containing `-`) inside a form as editable for plain keys, for closed shadow roots, where `composedPath()` stops at the host.
#### B-WR-02: Ctrl/Cmd+S and Ctrl/Cmd+Enter save without the taglist's typed-but-uncommitted tag
**File:** `admin/src/app/hotkeys.ts:187-206` (`target.click()` at `:205`); `admin/src/components/form/fields/TaglistField.vue:73`, `:167`
**Issue:** TaglistField commits the draft tag only on Enter (`onKeydown`) or on blur (`@blur="addDraft"`). With the mouse, pressing Save first blurs the input (on mousedown), so the draft is committed before the click handler reads `values`.
The shortcut path calls `target.click()` without moving focus, so no blur happens. `onModifierKey` also runs in the window capture phase and calls `stopPropagation()`, so for Ctrl+Enter the taglist's own Enter handler never runs either.
Failure scenario: the admin types `news` in a tags field and presses Ctrl+Enter (Save and close). The record is saved without the tag and the view navigates away, losing the input silently. Ctrl+S leaves `news` visible in the input after a "saved" toast, but the stored record does not have it. Any plugin field type that commits on the native `change` event (which fires on blur) has the same problem.
**Fix:** commit pending edits before clicking, the way a pointer click would:
```ts
const active = doc.activeElement as HTMLElement | null
if (active && active !== target && typeof active.blur === 'function') {
active.blur() // fires blur/change: taglist addDraft, native change
}
target.click()
```
Optionally restore focus after the save. Add a test that drafts a tag and presses Ctrl+S.
#### B-WR-03: An account action's URL goes into `href` and `window.open` with no scheme check (`javascript:` executes on the admin origin)
**File:** `admin/src/components/account/AccountContributions.vue:43-46`, `:103-111`; `modules/cabana/account_contributions.go:212`
**Issue:** `AccountActionResult.URL` is passed through unchanged by the server (`URL: result.URL`) and the SPA:
- binds it to `<a :href="links[section.name]">`;
- calls `window.open(answer.url, '_blank', 'noopener')`.
Vue does not sanitize `href`. A `javascript:` (or `data:`) URL therefore runs script with the admin's session the moment the admin clicks the visible link. That is stored-XSS-equivalent in the admin if any plugin builds the URL from data it does not fully control, for example a configured site URL or a value from the linked frontend account.
T-19.7-08 records only "no v-html" and "noopener" as the mitigation, so this sink is not covered by the accepted risk.
**Fix:** allow only http(s) (and same-origin relative) URLs at the framework seam, and again in the SPA:
```go
// account_contributions.go, before WriteData
if result.URL != "" {
u, err := url.Parse(result.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
moduleLog.ErrorContext(ctx, "cabana: account contribution action answered a non-http URL", "section", item.section.Name, "action", action.Name)
result.URL = ""
}
}
```
```ts
const safe = /^https?:\/\//i.test(answer.url) ? answer.url : ''
```
#### B-WR-04: Revoking any personal token ends every MCP session of the admin, including OAuth agent sessions of the linked frontend user
**File:** `modules/cabana/account_tokens.go:167`
**Issue:** `fireSubjectChanged(..., pact.MCPSubjectRevoked, principal.ID)` names only the admin. `pier/events.go:42-50` closes and deletes every local session whose identity `has("backend", adminID)`. That includes:
- sessions opened with other, still-valid `g15_` tokens;
- every OAuth session whose linked backend subject is this admin, which also carries the frontend user's tools.
Stateless servers are evicted too. The comment says "A revoked token ends what it opened elsewhere", but the effect is per admin, not per token.
Failure scenario: an admin revokes an old CI token. Every running agent session of their linked frontend account (and of their other tokens) is torn down on all instances. Per-request bearer verification already refuses the revoked token on its next request, so the broad kill adds no security for the revoked token itself.
**Fix:** make the event token-specific. Add `TokenID uint` to `pact.MCPSubjectChanged` (additive), fire it with `row.ID`, and in `pier.handleEvent` close only sessions whose `Identity.Backend.TokenID == ev.TokenID && Identity.Backend.ClientID == ""` when `TokenID != 0`. Until then, drop the event here and rely on the per-request re-verify.
#### B-WR-05: The record action API contract still says payload is refused
**File:** `modules/cabana/admin_openapi.go:798`, `:808`, `:825-827`
**Issue:** The changes in this phase make `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}` accept `payload` (a JSON string) for a prompted action, and validate it with a 422 on `payload`. Three annotations were not updated:
- the `AdminRecordAction` description still says "The body must be {}: record_id, values and payload are all refused";
- the body param is documented as "Empty object";
- `AdminActionRequest.Payload` says "Toolbar and record routes refuse it."
These texts are generated into `admin/openapi/admin.json` and `admin/src/api/schema.d.ts`, so the published contract contradicts the server. The project rule (CLAUDE.md) requires docs to change with the API.
**Fix:**
- Update the description to: "The body must be {} for an action without a prompt; an action whose record response carries `prompt` takes `{\"payload\": \"<text>\"}` (trimmed, non-empty, at most prompt.max_length runes; 422 on payload otherwise)."
- Change the body param text and the `Payload` field comment to match.
- Run `scripts/check-admin-openapi.sh`.
#### B-WR-06: One contributing plugin's Status/Applies error removes every My account section, and the SPA hides the failure
**File:** `modules/cabana/account_contributions.go:110-116`, `:126`; `admin/src/components/account/AccountContributions.vue:25-32`
**Issue:** `accountContributionsList` answers 500 for the whole list as soon as any section's `Status` or any action's `Applies` returns an error. `load()` then sets `sections = []` on any non-2xx result (`result.data?.data ?? []`) with no toast.
Failure scenario: plugin A's status query fails (a missing table after a partial migration, or a timeout). Plugin B's unrelated section, such as the MCP account link with its Unlink button, disappears from My account with no message. The admin cannot reach B's actions until A is fixed.
**Fix:** isolate failures per section. Log the error, then either omit that section or send it with an empty status and an `error: true` flag. Keep the other sections. In the SPA, show a toast (`backend::lang.form.error_generic`) when the GET fails, instead of silently rendering nothing.
### Info
#### B-IN-01: My account confirm and stale texts talk about "this record"
**File:** `admin/src/components/account/AccountContributions.vue:52`, `:78`
**Issue:** The default confirm uses `backend::lang.form.action_confirm` ("Run “:action” on this record?" / "Wykonać „:action” na tym rekordzie?"). A 409 shows `action_stale` ("This action no longer applies to this record. The page has been refreshed."). My account has no record, so both texts are wrong there.
**Fix:** add `backend::lang.myaccount.action_confirm` and `backend::lang.myaccount.action_stale` (en and pl, plus `tests/fixtures/lang.json`) and use them here.
#### B-IN-02: One prompt refusal is not localized
**File:** `modules/cabana/actions.go:268`
**Issue:** `"The payload must be a string."` is hard-coded English. The other two prompt refusals use phrase keys. RecordActions shows `details.payload[0]` as the toast, so a Polish admin can see English. It is reachable only by a non-SPA client, so impact is low.
**Fix:** reuse `promptRequiredKey`, or add `backend::lang.form.action_prompt_invalid`.
#### B-IN-03: Plugin status text goes through `translateKey`, not `localizeDetail`
**File:** `modules/cabana/account_contributions.go:116`
**Issue:** `Status` is typically dynamic text such as "Linked to jan@example.com". `translateKey` looks every string up as a phrase key. In development that logs a missing-key warning on every My account load, which `localizeDetail` (crud.go) exists to avoid.
**Fix:** `view.Status = localizeDetail(ctx, tr, status)`.
#### B-IN-04: Failed `g15_` token attempts at `/mcp` are logged without the client address
**File:** `modules/cabana/token_resolver.go:31`, `:38`, `:43`, `:47`, `:51`, `:61`
**Issue:** `ResolveBackendToken` takes no request, so every `admin.auth` line it writes has `method=""` and `remote=""`. Over HTTP, the same failures carry the remote address, which the audit relies on to trace token guessing.
**Fix:** take the remote address (or an `*http.Request`) as an optional parameter. pier has the outer request when it resolves the bearer.
#### B-IN-05: A prompted action refused with 422 closes the dialog and discards the typed text
**File:** `admin/src/components/form/RecordActions.vue:288-315`, `:336-339`; `admin/src/components/ui/confirm.ts:50-67`
**Issue:** `confirm()` always settles after the action, so a 422 on `payload` (for example over the server cap on a non-SPA-checked path, or a plugin's own ValidationError on `payload`) shows a toast and the input is gone. The pact doc says the admin refuses such a value "with a validation error on the input".
**Fix:** let the action return `false` to keep the dialog open, show `detail` under the input (`aria-invalid`, `aria-describedby`), and keep `value`.
#### B-IN-06: `window.open` after an awaited POST can be blocked by popup blockers
**File:** `admin/src/components/account/AccountContributions.vue:45`
**Issue:** The open runs after `await api.POST(...)` (and after the confirm dialog). That is outside the click's synchronous user-activation window in Safari, and in Chrome when the request is slow. The visible link (`:103-111`) is the fallback, so nothing is lost.
**Fix:** none required. Optionally skip `window.open` and rely on the link, or open a blank window synchronously and set its `location` afterwards.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_
# Lane C
## Phase 19.7: Code Review Report (lane C: plugins and demo)
**Reviewed:** 2026-10-23
**Depth:** deep (call chains followed into summercms.go `bouncer`, `cabana`, `lagoon`, `pier`, `wristband`)
**Files Reviewed:** 59
**Status:** issues_found
Repository paths used below:
- **U** = sm-user-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user`, baseline 3828a65)
- **O** = sm-oauthserver-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin`, baseline d31f8e7)
- **J** = sm-journal-plugin (`/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, baseline ffa2c8f)
- **D** = sm-demo-app (baseline a25444e^)
- **V** = vue-demo-app (`sm-demo-app/vue-demo-app`, baseline f397df2)
- **F** = summercms.go (framework, read for call chains only)
### Summary
These parts hold up under review: the link-request storage (hashed 256-bit code, row lock, one-shot `used_at`, 1:1 unique indexes plus the 23505 mapping), the consent intersection (granted = submitted ∩ requested, `backend` dropped without a link), the Journal `optionalBackendPrincipal` change (`cabana.ResolveBackend` reads only the bearer or the dispatch marker, never the admin cookie), the Vue pages (text interpolation only, no `v-html`; Nuxt 4.4.8 `navigateTo` refuses script protocols, and wristband DCR requires a hostname), and the migration and its rollback. No breaking API change was found in U, O or J. All additions are new methods, routes, events or hooks.
The review found four real defects, two of them in security controls the phase security review lists as "verified":
1. Banning a frontend user, or resetting their password, does not end their MCP grants. Through the account link, a banned user keeps acting as the linked administrator (C-CR-01).
2. The "fresh sign-in" proof of the account link (D-05) compares the JWT `iat` with the request time. `POST /_user/api/v1/refresh` mints a new `iat` without any sign-in, so a long-lived session passes as fresh (C-CR-02).
3. The link code is a bearer secret that links whoever confirms it. It travels in request paths and query strings that nginx logs, which contradicts the security review's "the link code is never logged" (C-WR-01).
4. An MCP grant that carries `backend` is not bound to the administrator shown on the consent page. A later link to a different administrator re-targets the existing grant without new consent (C-WR-02).
Not re-reported: the missing self-service revoke of MCP grants (AR-19.7-03), superuser reach (T-19.7-34), the v0.4.4 pin gaps, and the other items in deferred-items.md.
### Narrative Findings (AI reviewer)
### Critical Issues
#### C-CR-01: Banned users and password-reset users keep their MCP grants, including the linked admin's backend reach
**File:** `O classes/mcp_verifier.go:89-95`; `U classes/backend_links.go:396-401`; `U classes/admin_actions.go:106-120` (RevokeUserAccess), `:182-212` (BanUsers); `U controllers/api_controller.go:449-456` (password reset)
**Issue:** `MCPTokenVerifier.MCPVerifyToken` accepts a token whenever `userclasses.PrincipalByID` loads the user. `PrincipalByID` (`U classes/user_lookup.go:35-60`) checks only that the row exists and is not soft-deleted. It does not check the ban flag in `user_throttle`, and it does not compare the token's `created_at` with `users.tokens_valid_after`. The plugin's ban and revoke paths do not touch OAuth tokens:
- `BanUsers` calls `RevokeUserAccess`, which stamps `tokens_valid_after` and revokes `user_api_tokens` only. It fires no event: no `DeactivatingEvent`, so oauthserver's `Cascade` never runs, and no `pact.MCPSubjectChanged`.
- Password reset (`api_controller.go:453-455`) and password change (`:715`) only set `tokens_valid_after`.
- `LinkedBackendUser` (`backend_links.go:396-401`) also ignores bans.
Pier has no other user-state check (`F pier/identity.go:162-199`). MCP refresh tokens (`O classes/mcp_store.go:275-311`) are rotated with no user check either, so the grant survives access-token expiry as well.
**Failure scenario:** An editor's frontend account E is linked to admin A and holds an MCP grant with `read write backend`. Staff ban E on the Users screen after abuse, or E's owner resets the password after a compromise. The attacker's agent keeps calling `journal_update_post` and `journal_delete_post` as admin A, and keeps refreshing its access token. The plugin documents the ban as ending "every session JWT ... and every personal API token", but the MCP grant is a session-equivalent credential that this phase newly made usable against real routes.
**Fix:** Make the verifier honour the same refusals as `/refresh` (`refreshSubjectRefused`). Also revoke on ban or reset, and fire the session-ending event:
```go
// O classes/mcp_verifier.go, after loading principal
if principal.TokensValidAfter != (time.Time{}) && token.CreatedAt != nil &&
token.CreatedAt.Before(principal.TokensValidAfter) {
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
banned, _, err := userclasses.ThrottleStates(ctx, db, []uint{token.UserID})
if err != nil {
return pact.MCPGrant{}, err
}
if banned[token.UserID] {
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
```
In U, make `RevokeUserAccess` fire `MCPSubjectRevoked` after commit for each id, or add a `user.AccessRevoked` event that oauthserver's `Cascade` handles by revoking the user's access and refresh tokens. Add `AND NOT EXISTS (SELECT 1 FROM user_throttle t WHERE t.user_id = l.user_id AND t.is_banned)` to `LinkedBackendUser`. These are additive.
#### C-CR-02: The account-link "fresh sign-in" proof (D-05) is defeated by token refresh
**File:** `U classes/backend_links.go:239`; `U controllers/backend_link_controller.go:74-85`; `F modules/bouncer/refresh.go:137` with `F modules/bouncer/mint.go:48-60`; `U controllers/api_controller.go:240`; `V app/pages/link-admin.vue:86-91`
**Issue:** `ConfirmLinkRequest` treats `iat >= request.created_at` as proof that the user signed in for this link. `POST /_user/api/v1/refresh` (`bouncer.Refresh` → `MintAudience`) mints a new token with `IssuedAt: now` from any token still inside the refresh window. The demo sets `refresh_ttl` to 43200 minutes (30 days), and refresh needs no password, no social login and no 2FA. Refreshing after the request was created therefore turns a weeks-old session into a "fresh" one. Clients do this automatically: the demo's own API client refreshes on any 401 (`V app/lib/api/http.ts:127-183`), and so does sm-bm-app's. The link page also never shows which frontend account is about to be linked; it shows only the admin's name.
The security review rows T-19.7-22 and cross-cutting finding 1 record this control as "verified". It is not: the property D-05 calls one-way ("a link created without proof ... cannot be told apart afterwards") does not hold.
**Failure scenario:** An administrator starts "Link frontend account" and opens `/link-admin?code=…` on a shared or family computer where the site is still signed in as someone else (X). X's session refreshes when any page gets a 401, or X's script calls `/refresh`. The admin clicks "Link the accounts", sees "Your account is now linked", and X's account now holds the admin's backend reach for every MCP client X connects with `backend`. Nothing on the page tells the admin that X, not they, is signed in.
**Fix:** Bind the proof to an actual authentication event that refresh carries forward unchanged:
- Mint an `auth_time` claim at login, registration, social login and 2FA completion. Have `bouncer.refreshAudience` copy the existing `auth_time` into the rotated token instead of resetting it.
- In `BackendLinkConfirm`, read `auth_time` (refuse when it is absent) and compare that with `req.CreatedAt`, not `iat`.
Until then, a smaller fix: record `last_login_at` per sign-in and require `auth_time`/`last_login_at >= created_at` together with a `jti` minted by that login. In `link-admin.vue`, show the signed-in account's email next to the confirm button ("You are signed in as …"), so the admin can catch the wrong account.
### Warnings
#### C-WR-01: The one-shot link code links whoever presents it, and it is written to nginx access logs
**File:** `U routes.go:45-46` and `U controllers/backend_link_controller.go:36-38` (`GET /_user/api/v1/backend-link/{code}`); `U account_contributions.go:122` (`/link-admin?code=<code>`); `V app/pages/link-admin.vue:15-16, 37`; `D deploy/nginx/demo.summercms.io.conf:96` (`location ^~ /_user/`, default access log)
**Issue:** `ConfirmLinkRequest` binds the link to the frontend user who confirms. The code is the only proof of the admin's intent, so anyone who sees it within the TTL can sign in to their own account and link it to the admin. The code reaches the server in the URL path of the show call and in the query string of the SSR page load (`/link-admin?code=…`, proxied to Nuxt by `location /`). nginx's default `access_log` records both request lines, as do Nuxt/Nitro request logs and any log shipping. The security review (cross-cutting 6) says "The link code is never logged", which is wrong for these logs. Through `/login?next=/link-admin?code=…` the code also lands in browser history.
**Failure scenario:** Someone with read access to the web server logs, or to a log pipeline or analytics collector, takes the code within 10 minutes. They sign in to their own frontend account and POST `/backend-link/confirm`. Their account is now linked to the admin, and a `backend` MCP grant acts as that admin. The admin only sees "link expired or used", and later "Linked to <someone else's email>" on My account.
**Fix:**
- Keep the code out of every URL the server sees: answer `…/link-admin#code=<code>` (the fragment is not sent or logged), have the page read `location.hash`, and change the show route to `POST /_user/api/v1/backend-link/show` with the code in the JSON body.
- Optionally add `access_log off` or a redacting `log_format` for `/link-admin`.
- For defence in depth, require the admin to approve on My account the frontend email that confirmed (two-sided confirmation) before the link becomes active.
#### C-WR-02: A `backend` MCP grant is not bound to the administrator the user consented to; a later link re-targets it silently
**File:** `O controllers/mcp_consent_api.go:133-145`; `U classes/backend_links.go:322-348` (`RemoveLink`), `:293-320` (`insertLink`); `F pier/identity.go:187-198`; `V i18n/locales/en.json` `connect.actAsAdmin`
**Issue:** At consent the page states "It will also act as admin {name} ({login})", and `backend` is granted when some link exists. The grant stores only the scope string. Pier resolves whichever administrator is linked at each request. Unlink fires only `MCPSubjectLink`, which recomputes the tool list. The oauthserver plugin does not listen to `BackendUnlinkedEvent` or `BackendLinkedEvent`, so the token and refresh token keep `backend` after unlinking. When the user is later linked to a different administrator, by self-service or by a holder of `golem15.users.manage_backend_links` via `SetLink`, the old grant resumes with the new admin's permissions, and nobody was asked again. D-08 decided not to store the link in the grant. That decision covers revocation, but not this re-targeting.
**Failure scenario:** User U connects an agent while linked to editor admin X (consent shows X). U is unlinked. Months later an administrator links U's email to superuser admin Y for onboarding (no proof needed on that path). U's long-lived agent, running on refresh tokens, now acts as superuser Y, although U only ever approved X.
**Fix:** Pick one of these:
- Have oauthserver listen to `*userclasses.BackendUnlinkedEvent` and revoke that user's MCP access and refresh tokens that carry `backend` (in the event's transaction), so a new link always needs a new consent.
- Store the consented `backend_user_id` with the grant (access and refresh rows) and have the verifier expose it in `pact.MCPGrant`, so pier refuses the backend subject when the current link differs. This is an additive field.
### Info
#### C-IN-01: The `journal_draft_post` prompt is listed for read-only grants although it directs a write tool
**File:** `J mcp.go:154-165, 179-183`; `F pier/filter.go:48-58`
**Issue:** `allowsPrompt` applies no read/write ceiling. A `read` grant sees `draft_post`, whose text tells the agent to call `journal_create_post`, a tool that grant does not have.
**Fix:** Add an additive `Writes bool` (or `ReadOnly *bool`) to `pact.MCPPrompt` that the ceiling honours, and set it on `draft_post`.
#### C-IN-02: Revoking one personal token or one OIDC app ends every MCP session of the user
**File:** `U controllers/api_tokens.go:110-111`; `O controllers/authorizations.go:121-122`; `F pier/events.go:42-50`
**Issue:** Both fire `MCPSubjectRevoked` for the whole user. Pier closes every session of that subject, including unrelated OAuth MCP sessions. Clients must re-initialize. Access is not widened, so this is only an availability issue.
**Fix:** Fire the event only when the deleted token or client is the one a session is bound to, or carry a token or client id in the event and match on the session binding.
#### C-IN-03: nginx body cap (2 MiB) is below pier's 4 MiB MCP body cap
**File:** `D deploy/nginx/demo.summercms.io.conf:65` (server-level `client_max_body_size 2m`), `:213-226` (`location = /mcp`)
**Issue:** A `tools/call` between 2 and 4 MiB (for example a long `journal_create_post` body) gets nginx's HTML 413 instead of pier's JSON-RPC error.
**Fix:** Add `client_max_body_size 4m;` inside `location = /mcp`, and check it in `check-deploy.sh`.
#### C-IN-04: The personal-token MCP path does not stamp `last_used_ip`
**File:** `U classes/mcp_guard.go:169`, compared with `U middleware` `APITokenGuard` (`:71-78`)
**Issue:** The `user.api_token` guard records `last_used_ip`. `MCPResolvePersonalToken` stamps only `last_used_at`, so a token used through `/mcp` (with `mcp.allow_personal_tokens` on) shows a stale IP on the tokens page.
**Fix:** Pass the client IP through `ctx` (pier copies the client address onto the request) and stamp it as the guard does.
#### C-IN-05: Dead and duplicated code
**File:** `U models/backend_link_request.go:27` (`IsUsableAt`, never called; `classes/backend_links.go:193` `requestUsable` duplicates it); `U classes/subject_events.go` and `O classes/subject_events.go` (identical `FireSubjectChanged` and `FireSubjectChangedAfterCommit`); `U account_contributions.go:57-64` and `U controllers/backend_link_controller.go:15-22` (the same `backendLinks` lookup)
**Fix:** Have `requestUsable` call `req.IsUsableAt` (keep the error split), and have oauthserver call `userclasses.FireSubjectChanged*` (it already imports U).
#### C-IN-06: The link page path is hard-coded for every consuming site
**File:** `U account_contributions.go:122`
**Issue:** `golem15.user.frontend_url` sets only the origin. Every site using this core plugin must serve exactly `/link-admin`. A site with locale-prefixed routes, or an API-only `app.url` without `frontend_url`, sends admins to a 404.
**Fix:** Add `golem15.user.backend_links.page_path` (default `/link-admin`). This is additive.
---
_Reviewed: 2026-10-23_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: deep_

View File

@@ -0,0 +1,191 @@
# Phase 19.7 Security Review: generic MCP module
**Date:** 2026-10-23
**Level:** OWASP ASVS L1 (RESEARCH "Security Domain")
**Scope:** the framework seams of plan 01 (bouncer dispatch marker, cabana `ResolveBackend` and the account and prompted-action seams, surf's compiled-handler lookup and stream route kind, wristband's scope allow-list), the `pier` module of plan 02, sm-user-plugin's account link, frontend permission catalog and MCP guard (plan 03), sm-oauthserver-plugin's MCP verifier and consent JSON, sm-journal-plugin's MCP tools and the demo wiring (plan 04), and plan 05's gate.
**Threats reviewed:** T-19.7-01..T-19.7-41 and T-19.7-SC from the threat models of plans 01-05; T-19.7-42..T-19.7-65 of the gap-closure plans 06-09 in the section "Gap closure (plans 06-09, 2026-10-23)", which also corrects the verdicts the phase code review (`19.7-REVIEW.md`) disproved.
**Result:** every threat is verified, fixed in a commit or accepted with a reason. The review found two production defects (a frontend dispatch marker honoured by cabana's `backend` middleware, and an ended session written back by a request still in flight) and fixed both in their own commits. The superuser verdict (D-22, T-19.7-34) is "accepted as designed with audit and ceiling"; no switch was added and no tag is needed for it.
Repositories and abbreviations used below: **F** = `summercms.go` (framework, paths under `modules/`), **U** = sm-user-plugin (`sm-demo-app/plugins/golem15/user`), **O** = `sm-oauthserver-plugin`, **J** = `sm-journal-plugin`, **D** = `sm-demo-app`, **V** = `sm-demo-app/vue-demo-app`.
## Method
1. For each threat, read the code that implements the mitigation and record the file and line (grep-level check at L1, plus a read of the surrounding logic).
2. For each threat, find the test that pins the mitigation (the plan 01-04 smokes and the plan 05 tests). Where the code was right but no test pinned it, add the test in the owning repository as its own commit.
3. Where the code was wrong, fix it in its own `fix(` commit in the owning repository, then add or extend the pinning test.
4. Cross-cutting sweeps (below): link proof, dispatch marker, session binding, scope ceilings, superuser reach, logs, `no-store`.
5. Run the phase gate `bash scripts/check-phase19.7.sh --all` on a Docker host: every stage PASS.
6. (Added in the gap closure, plan 09.) A high or critical control is "verified" only with a test that fails without it. Reading the code and finding a passing test is not enough: the plan 05 review marked T-19.7-22 verified from a test that never refreshed a token. For the three critical findings of the code review this is shown by a revert-check: the fix is reverted in a scratch worktree, the pinning test fails, and it passes at HEAD (see "Revert-checks" below).
## Gaps fixed during the review
| Commit | Repo | Threats | What changed |
|--------|------|---------|--------------|
| c49983d | F | T-19.7-01, T-19.7-02 (new finding) | `fix(cabana)`: cabana's `backend` middleware (the admin API and every route behind `backend`) honoured a dispatch marker listing the `backend` guard even when its principal was a frontend one, so a frontend user id would have been read as an administrator's. `refuseFrontendMarker` now answers 401 for such a marker before the guard honours it, as `ResolveBackend` already did. Pinned by `TestDispatchMarker` (cabana, Postgres part, commit 3bbbe6c). No framework caller produces such a marker today (pier's backend provider builds backend principals only); this closes the seam for any future dispatcher or guard provider. |
| 1111828 | F | T-19.7-15, T-19.7-35, T-19.7-11 | `fix(pier)`: a request that finished after its session ended (DELETE, a `revoked` or `deleted` subject event, the idle expiry) persisted the session state with a plain Put and brought the record back; the next request rehydrated the ended session. The persist step now only rewrites an existing record (Redis `SET XX`, the memory store likewise) and closes the local copy when the record is gone. Found by `TestFanOut` (about one run in a hundred before the fix; 300 consecutive green runs after it). |
| 0b8e027 | F | T-19.7-17, T-19.7-18, T-19.7-19, T-19.7-37 | Test pins: `TestEndpointGuards` (foreign Origin 403 before the bearer is read, 413 over the body cap, a GET stream ends once its grant stops verifying), `TestDispatch` refuses an undeclared argument key, the gate requires both and runs the demo's deploy check. Code unchanged. |
| 3bbbe6c, f14eae9 | F | T-19.7-01..07, T-19.7-09..20, T-19.7-22 | The validation-map tests of Tasks 1-2 (`TestDispatchMarker` x2, `TestToolsList`, `TestCatalog`, `TestDispatch`, `TestGuardRules`, `TestSessions`, `TestFanOut`, `TestStreamingRoute`, `TestPierMount`, `TestMCPScopes`, `TestAccountContributions`, `TestRecordActionPrompt`, `TestScopeLimitForMethods`). |
| 72de822 | U | T-19.7-22..29 | `TestBackendLink`, `TestFrontendPermissionCatalog`, `TestUserMCPGuard`. Code unchanged. |
| 2929c91 | O | T-19.7-30..33, T-19.7-35 | `TestMCPTokenVerifier`, `TestMCPConsent`. Code unchanged. |
| a9a9b80 | J | T-19.7-36, T-19.7-17 | `TestJournalMCP`. Code unchanged. |
| 6fa2d56 | D | T-19.7-13, T-19.7-15, T-19.7-35, SC-4 | `TestMCPEndToEnd` revoke matrix (unlink list_changed, read grant, revoked grant ends the session, personal tokens off). Code unchanged. |
A third fix found by the tests is functional, not a security gap: c29d228 `fix(pier)` treats a `tools/call` without arguments (or with `null`) as an empty object (`TestDispatch`).
## Threat register
| Threat | Mitigation as built | Implementation | Test (repo) | Status |
|--------|---------------------|----------------|-------------|--------|
| T-19.7-01 | The marker lives under an unexported context key; `Dispatched` is the only reader and answers only for an exact guard name with a principal; no header, cookie or query is read for it; the only non-test caller of `WithDispatch` is pier's dispatcher (the gate's `marker` stage); cabana's `backend` middleware refuses a frontend marker. | F `bouncer/dispatch.go:5`, `:29`, `:40`; F `bouncer/registry.go:86`; F `pier/dispatch.go:159`; F `cabana/http.go:252` | `TestDispatchMarker` (bouncer: forged headers, cookies and query; nil and unlisted markers), `TestDispatchMarker` (cabana: over HTTP the marker never arrives), gate stage `marker` (F) | fixed in commit c49983d |
| T-19.7-02 | `ResolveBackend` honours a marker only for guard `backend` with `Principal.Backend`; a frontend marker is `ErrUnauthenticated`; a scoped principal is still checked against the cover codes; the `backend` middleware applies the same rule. | F `cabana/token_guard.go:209-216`; F `cabana/http.go:252` | `TestDispatchMarkerSmoke`, `TestDispatchMarker` (cabana, incl. the in-process admin API case) (F) | fixed in commit c49983d |
| T-19.7-03 | `Router.Handler` returns the fully compiled chain (constraints, named middleware, body limit, recovery, CORS profile, pattern), the same chain the mux serves. | F `surf/routetable.go:49`; F `surf/router.go:373` | `TestDispatchSmoke` (F), `TestDispatchMarker` (cabana, Journal-shaped route through `Handler`) (F) | corrected: fixed in 104db99 (F); was verified (see Corrections) |
| T-19.7-04 | `recoverStream` writes the opaque 500 only before the first header, byte or flush; afterwards the body is cut short and the panic value is never written. | F `surf/streaming.go:63-80` | `TestStreamingRouteSmoke`, `TestStreamingRoute` (F) | verified |
| T-19.7-05 | `mcp.enabled: true` without the build tag fails boot naming `summercms_mcp`. | F `surf/router.go:701` | `TestPierMountSmoke`, `TestPierMount` (untagged branch) (F) | verified |
| T-19.7-06 | Account contribution routes sit behind the `backend` guard and `interactiveOnly` (personal tokens refused), `requireAjax` on POST, `allowAction` for the action's permissions, `Applies` re-checked before `Run` (409), `Run` inside a transaction with only the signed-in admin's id. | F `cabana/http.go:319`; F `cabana/account_contributions.go:169-181`, `:189` | `TestAccountContributionsSmoke`, `TestAccountContributions` (incl. the Postgres transaction case) (F) | verified |
| 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 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 |
| T-19.7-14 | One SDK server per identity; the receiving middleware re-filters `tools/list` and refuses `tools/call` and `prompts/get` outside the current request's list (JSON-RPC error, the route is never reached); stateless servers are keyed by binding. | F `pier/session.go:493`; F `pier/stateless.go:80` | `TestToolsList` (per principal; unlisted call never reaches the route), `TestPierStatelessSmoke` (F) | verified |
| T-19.7-15 | Rehydration builds the server from the current bearer's identity, never from stored state; `revoked`/`deleted` events delete the record and close local copies on every instance; a request finishing after the end no longer writes the record back; link events drop the link cache and recompute. | F `pier/session.go:230-280`, `:320-360`; F `pier/events.go:42-60`; F `pier/store_redis.go` (`Update`) | `TestFanOut` (revocation, deletion, link change, cache bypass), `TestSessions`, `TestMCPEndToEnd` "revoked grant ends the session" (F, D) | fixed in commit 1111828 |
| T-19.7-16 | Only `oas_at_` tokens reach the verifier; the verifier accepts only rows of a kind-`mcp` client; PRM `resource` is `mcp.resource` and a boot warning names a mismatch with the verifier's resource. | F `pier/identity.go:149`; F `pier/prm.go:23-31`; O `classes/mcp_verifier.go:79-88` | `TestMCPTokenVerifierSmoke` (OIDC client's token refused), `TestMCPEndToEnd` "protected resource metadata" (O, D) | verified |
| T-19.7-17 | Arguments are validated against the reflected schema (additionalProperties false, required fields); a single-segment value may not contain `/`; path and query keys are removed from the forwarded body; the route's own constraints and body limit still run. | F `pier/dispatch.go:85`, `:102`; F `pier/catalog.go:209`, `:340` (`inputFields`) | `TestDispatch` (undeclared key refused, `a/b` refused, omitted fields absent), `TestJournalMCP` (input keys are the route's keys) (F, J) | corrected: fixed in 104db99 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) |
| T-19.7-18 | Inline `mcp.throttle` on the endpoint routes, `max_per_binding` (429), idle TTL, 4 MiB body cap, `mcp.call_timeout`, `mcp.max_result_bytes`, stream re-verification every minute. | F `pier/pier.go:122`, `:225`; F `pier/session.go:139`, `:369`; F `pier/dispatch.go:145`, `:186` | `TestPierMount` (tagged: `throttle:120,1`), `TestStreamingRoute` (throttle on a stream route), `TestSessions` (429), `TestDispatch` (timeout, size cap), `TestEndpointGuards` (413, stream re-verification) (F) | corrected: fixed in 2bf9dca, c1c0c77 (F); was fixed in commit 0b8e027 (test pin; code unchanged) (see Corrections) |
| T-19.7-19 | `originCheck` answers 403 for a browser Origin outside `mcp.allowed_origins` (default: the app's own origin) before the bearer or the body is read; no Origin passes. | F `pier/pier.go:201-220` | `TestEndpointGuards` "origin check" (F) | fixed in commit 0b8e027 (test pin; code unchanged) |
| T-19.7-20 | The audit line carries tool, guard, user/admin/client ids, token kind, an 8-character session prefix, status and duration; never a token, argument or body. | F `pier/audit.go:40-66` | `TestPierTracerSmoke` "audit line" (F) | verified |
| T-19.7-21 | Descriptions are plugin-authored static text, capped at 1024 runes at boot. | F `pier/catalog.go:21` | `TestCatalog` (boot refusals) (F) | accepted (see Accepted risks) |
| T-19.7-SC | The user approved go-sdk v1.8.0 and jsonschema-go v0.4.3 before `go get`; versions pinned in go.mod; the decision note lists the transitive graph and govulncheck; the SDK links only into tagged binaries. | F `.planning/notes/2026-10-22-mcp-go-sdk.md`; F `.planning/research/STACK.md:56-57`; F `go.mod` | gate stages `docs` (note and STACK rows) and `go` (the untagged hello binary links no SDK) (F) | verified |
| T-19.7-22 | The self-service path writes a link only inside `ConfirmLinkRequest`: a one-shot hashed code bound to the admin, row lock, `jwt.auth` principal equal to the session token's subject, and the token's `auth_time` (the sign-in, which a refresh copies and never renews) not before the request's `created_at` (in seconds); a token without `auth_time` is `stale_session`; no email match and no user list | U `classes/backend_links.go:250` (`ConfirmLinkRequest`, `sessionAuthTime`); U `controllers/backend_link_controller.go:102` (`bouncer.VerifyAuthTime`); F `bouncer/jwt.go:214`, `bouncer/refresh.go:92` | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F), `TestBackendLinkAuthTimeSmoke`, `TestBackendLink` (U) | corrected: fixed in c14238b (F), 32f9259 (U); was verified (see Corrections) |
| T-19.7-23 | 64-hex code stored as SHA-256, `used_at` set in the linking transaction, expired, used and unknown codes all 404; the show route answers only the admin's login and name and the expiry. | U `classes/backend_links.go:105`, `:129`; U `controllers/backend_link_controller.go:47` | `TestBackendLinkSmoke` (replay, expiry), `TestBackendLink` (show route keys) (U) | corrected: fixed in 32f9259 (U), 0125e29 (V); was verified (see Corrections) |
| T-19.7-24 | Admin-set links need `golem15.users.manage_backend_links` (no default role), match an exact email, record `linked_by`, fire `BackendLinkedEvent` and both subject events after commit; cabana's outranking check runs first; 1:1 refuses a second link. | U `backend_links_admin.go:33`, `:50`, `:99`; U `classes/backend_links.go:269-325` | `TestBackendLinkAdminSmoke`, `TestBackendLink` ("SetLink and RemoveLink events", none after a rollback) (U) | verified |
| T-19.7-25 | Confirm is a POST behind `jwt.auth` with the bearer re-read for `iat`; My account link and unlink run behind the backend guard, `interactiveOnly` and `requireAjax`; no state change on GET. | U `controllers/backend_link_controller.go:60-74`; F `cabana/http.go:319` | `TestBackendLinkSmoke` (anonymous confirm 401), `TestAccountContributions` (personal token refused) (U, F) | verified |
| T-19.7-26 | `LinkedBackendUser` is one indexed query per call (no plugin cache) excluding deleted or deactivated admins and deleted users; user deletion and deactivation remove the link in their transaction and fire events after commit. | U `classes/backend_links.go:382-412`, `:455-490` | `TestBackendLink` ("deactivated or deleted administrator", "deactivating the frontend user"), `TestBackendLinkSmoke` (delete) (U) | verified |
| T-19.7-27 | `MCPAllows` evaluates the route's scopes and every permission code (route's and tool's) with the same group grants `user.permission` checks; an unknown `user.*` middleware fails boot. | U `classes/mcp_guard.go:58-117` | `TestUserMCPGuardSmoke`, `TestUserMCPGuard` (parser refusals, deleted user, dispatch credential against `user.scope:read`) (U) | verified |
| T-19.7-28 | Personal tokens resolve only for the plugin's prefix, by hash, `IsUsableAt` (revoked and expired refused) and a loadable user; the subject carries the token's own scopes; the dispatch credential is in memory (ID 0); personal tokens at `/mcp` need `mcp.allow_personal_tokens` (off by default and in the demo). | U `classes/mcp_guard.go:143-172`; F `pier/identity.go:152`; D `config/mcp.yaml:19` | `TestUserMCPGuard` (expired token, deleted user, ID 0 credential), `TestMCPEndToEnd` "personal tokens are off" (U, D) | verified |
| T-19.7-29 | `linked_by` on admin-set links, `created_at` on links and requests, link and unlink events, cabana's record action audit. | U `classes/backend_links.go:293-322` | `TestBackendLinkAdminSmoke`, `TestBackendLink` (`linked_by`) (U) | verified |
| T-19.7-30 | The verifier gate: `oas_at_` prefix, hash lookup, not revoked, not expired, client of kind `mcp`, active and not revoked, user loadable. | O `classes/mcp_verifier.go:55-110` | `TestMCPTokenVerifierSmoke`, `TestMCPTokenVerifier` (deactivated client, user deactivated after minting, conditional stamp) (O) | verified |
| T-19.7-31 | The consent JSON shows the client name (capped), the redirect host and the linked admin only when `backend` was requested and a link exists; `backend` is granted only with a link at consent. | O `controllers/mcp_consent_api.go:54-92`, `:134-142` | `TestMCPConsentSmoke` ("no link", "linked") (O) | corrected: fixed in 671513d (O), 970719c (U); was verified (see Corrections) |
| T-19.7-32 | `PendingRequest`, `IssueCode` and `DenyPending` resolve the request for the acting JWT principal only; a foreign or replayed request is 404; answers are `no-store, private`. | F `wristband/consent.go:47`, `:74`, `:106`, `:130`; O `controllers/consent_api.go:123-133` | `TestMCPConsentSmoke` (foreign request), `TestMCPConsent` (replayed decision, no-store) (O) | verified |
| T-19.7-33 | granted = submitted ∩ requested, minus `backend` without a link; an empty grant is 422 `no_grantable_scopes`; a non-JSON body is 400. | O `controllers/mcp_consent_api.go:105-165` | `TestMCPConsentSmoke`, `TestMCPConsent` (extra scopes dropped, `scopes: []`, form body) (O) | verified |
| T-19.7-34 | The backend principal is the admin's real principal, superuser included (D-22). Reach is bounded by: the explicit `backend` scope on the consent page with the admin's name, the fresh-sign-in link proof, the read/write ceiling, the tools plugins declare (no generic admin API), the per-call audit line with admin_id and client_id, and per-request re-checks. | F `pier/backend_guard.go:59-100`; F `pier/audit.go:40` | `TestToolsList` (ceiling, union), `TestScopeLimitForMethods` (an admin token's scope per method), `TestMCPEndToEnd` (read grant hides write tools) (F, D) | accepted (see Superuser reach) |
| T-19.7-35 | Revocation from the authorized-apps list and the user cascade fire `revoked`/`deleted` events that end sessions everywhere; the verifier refuses a revoked token on the next request; an ended session is no longer written back. MCP grants have no consent row, so the authorized-apps list cannot revoke them yet (a revoked client or deleted user still ends every grant). | O `controllers/authorizations.go:101-122`; O `classes/cascade.go:68`; F `pier/events.go:42`; F `pier/session.go:320` | `TestMCPConsentSmoke` ("revocation fires revoked", "cascade fires deleted"), `TestMCPEndToEnd` ("revoked token", "revoked grant ends the session"), `TestFanOut` (O, D, F) | corrected: fixed in 970719c (U), 671513d (O); was fixed in commit 1111828 (see Corrections); the missing self-service revoke stays accepted |
| T-19.7-36 | Journal's update input fields are pointers or `omitempty`; pier forwards the agent's raw JSON minus path keys, so omitted fields stay omitted. | J `mcp.go:52` (`mcpUpdatePostInput`); F `pier/dispatch.go:91-123` (path and query keys removed from the forwarded body) | `TestDispatch` ("argument split": omitted `note` absent), `TestJournalMCP` (input keys) (F, J) | verified |
| T-19.7-37 | pier's inline throttle, idle TTL and per-binding cap; nginx opens exactly `= /mcp` (unbuffered, 1 h read timeout), the two MCP metadata paths and `/oauth/mcp/`; `check-deploy.sh` refuses anything else. | D `deploy/nginx/demo.summercms.io.conf:213-224`; D `scripts/check-deploy.sh` | gate stage `consumers` runs `check-deploy.sh`; `TestPierMount` (tagged throttle) (D, F) | fixed in commit 0b8e027 (gate pin; config unchanged) |
| T-19.7-38 | The consent page navigates only to the `redirect_uri` the server answers, which wristband builds from the client's registered URI; the login hop keeps a same-origin `next`. | V `app/pages/connect.vue:48`, `:57`, `:70-75`; F `wristband/consent.go:74-128` | `TestMCPConsentSmoke` ("deny": the redirect carries the registered URI's state) (O) | verified |
| T-19.7-39 | Every required test is listed by name; the detectors refuse failed, skipped, zero-match, "no tests to run" and missing names; the self-test proves them on planted output; docker is NOT RUN only without a daemon and fails with `PHASE197_REQUIRE_DOCKER=1`. | F `scripts/check-phase19.7.sh` (`detect_go`, `detect_vitest`, `run_self_test`, `docker_gate`) | `bash scripts/check-phase19.7.sh --self-test`; the go stage refused `TestFireSubjectChangedWaitsForCommit` when it skipped under `-short` during this plan (F) | verified |
| T-19.7-40 | The `marker` stage fails on any non-test caller of `WithDispatch` outside `modules/pier` and `modules/bouncer` and on a `modules/pact` dependency on the SDK, jsonschema-go or bouncer. | F `scripts/check-phase19.7.sh` (`run_marker`, `marker_callers`) | `--self-test` (planted caller found, pier and test files ignored), `--marker` (F) | verified |
| T-19.7-41 | Tests mint their own tokens and codes on throwaway Postgres containers and miniredis; no fixture holds a production credential; the docs stage keeps consuming applications out of the phase's framework tests. | F `modules/pier/helpers_test.go` (fake grants); U, O, D test harnesses (testcontainers) | gate stage `docs` (names) (F) | verified |
## Cross-cutting findings
1. **Link proof** (corrected in gap closure, C-CR-02). The only writers of `golem15_user_backend_links` are `insertLink` (called by `ConfirmLinkRequest` and `SetLink`) and the deletes in `RemoveLink`; `SetLink` has one non-test caller, the permission-guarded Administrators record action (U `backend_links_admin.go:99`, behind `golem15.users.manage_backend_links` and cabana's outranking check). The plan 05 review accepted a confirm that compared the token's `iat` with the request's `created_at`; a `POST /refresh` renews `iat`, so a browser signed in long before the request passed after one refresh. Since c14238b (F) every sign-in token carries `auth_time`, which refresh copies unchanged, and since 32f9259 (U) the confirm reads it with `bouncer.VerifyAuthTime` (U `controllers/backend_link_controller.go:102`) and compares it in whole seconds with `created_at` (U `classes/backend_links.go:250`); a token without `auth_time` is `stale_session`, and the token's subject must equal the `jwt.auth` principal. Pinned by `TestBackendLinkAuthTime` (refresh after the request stays stale; revert-checked) and `TestAuthTime`.
2. **Dispatch marker.** The only non-test caller of `bouncer.WithDispatch` is F `pier/dispatch.go:159` (gate stage `marker`). Nothing reads a header, cookie or query string for it (`TestDispatchMarker` sends all three). `ResolveBackend` and, since c49983d, the `backend` middleware refuse a frontend marker. The inner request carries no Authorization or Cookie (`TestDispatch`).
3. **Session binding.** The binding is compared on every stateful POST, GET and DELETE (F `pier/session.go:211`, `:242`); DELETE and revocation remove the record through the store and close every instance's copy (`TestSessions`, `TestFanOut`); rehydration takes identity from the current bearer (the stored record holds only binding, protocol state and creation time). Since 1111828 an ended session cannot be written back by a late request.
4. **Scope ceilings.** `read` hides tools whose annotations are not read-only (F `pier/filter.go:37`, `TestToolsList`, `TestMCPEndToEnd` "read grant"); `backend` without a link yields no backend subject (F `pier/identity.go:187`, `TestToolsList`); consent drops `backend` without a link (O `controllers/mcp_consent_api.go:134`, `TestMCPConsentSmoke`); personal tokens carry their own scopes and are off by default (F `pier/config.go`, `TestPierTracerSmoke`, `TestMCPEndToEnd`); an admin token's scope is filtered per dispatched method (`TestScopeLimitForMethods`, `TestGuardRules`).
5. **Superuser reach (P-23, D-22).** See the section below.
6. **Logs** (corrected in gap closure, C-WR-01). pier logs ids, names, statuses, durations, an 8-character session prefix and the binding key (`oauth:<client>:<user>`, which holds no secret); store errors carry cooler's fixed texts, not the Redis URL (F `cooler/redis.go:28-32`). The verifier's only log line names the token id (O `classes/mcp_verifier.go`). The plan 05 review said the link code stays out of every log; that was false: My account answered `/link-admin?code=<code>`, so the code reached the site's nginx and request logs, the browser history and the `GET /backend-link/{code}` access line. Since 32f9259 (U) and 0125e29 (V) the code travels only in the URL fragment (`#code=`), which a browser never sends, and in the body of `POST /backend-link/show` (`Cache-Control: no-store, private`); the page keeps it in sessionStorage across the sign-in and removes the fragment (U `account_contributions.go:120`, `controllers/backend_link_controller.go:45`; V `app/lib/linkCode.ts:20`, `app/pages/link-admin.vue:55`). The GET show route stays for compatibility and is documented as deprecated. No token, argument or body appears in any line (`TestPierTracerSmoke` "audit line"); the link transport is pinned by `TestBackendLinkShowPost` and `app/lib/linkCode.test.ts`.
7. **`no-store`.** Every consent answer (show, decide, refusals) carries `Cache-Control: no-store, private` (`TestMCPConsent`). The link show and confirm answers carry `no-cache, private` (U `controllers/api_controller.go:1266`) and hold neither the code nor a token. The one answer that carries a link code, My account's "link" action, is a POST response of the admin API, which shared caches do not store without explicit freshness; accepted at L1.
## Superuser reach
**Verdict: accepted as designed with audit and ceiling. `mcp.refuse_superuser_backend` is not added.**
Reasons:
- Scope of reach. An agent acting as a linked superuser reaches only the tools plugins declare (`pact.HasMCP`), each bound to one route; there is no generic admin API tool. The reach is that of the declared backend tools (today the seven Journal tools), not of the admin panel.
- Consent. The backend grant requires the frontend user to request it, see "also act as <admin name>" on the consent page, and hold a link made by a fresh-sign-in proof or by an administrator with `manage_backend_links`.
- Ceiling. A `read` grant hides every tool that is not read-only; scopes are per grant and the verifier refuses a revoked grant on the next request.
- Audit. Every call is logged with tool, admin_id, client_id and status.
- A switch would refuse a legitimate use (a solo site owner who is the superuser and the only author) while a cautious site already has the alternative: link the agent's frontend account to a non-superuser administrator with exactly the permissions the agent needs.
Revisit when a plugin declares a superuser-only or destructive backend tool beyond content editing; the switch is then a `fix(pier)` plus README and docs and a framework patch tag (recorded in deferred-items.md as a conditional follow-up).
## Accepted risks
| Risk | Threat | Reason |
|------|--------|--------|
| Tool and prompt descriptions are passed to agents unchanged | T-19.7-21 | They are static text the plugin author writes (capped at 1024 runes); dynamic text in a description is a plugin bug. The docs page tells authors to keep them factual. |
| Superuser reach through an agent | T-19.7-34 | See "Superuser reach". |
| No self-service revoke of an MCP grant | T-19.7-35 | MCP grants write no consent row, so the authorized-apps list neither lists nor revokes them (deferred-items.md). Access still ends: an administrator can revoke the client, deleting or deactivating the user ends every grant, the access token expires, and a revoked token is refused at once. A consent row per MCP grant is the follow-up. |
| A POST response carrying a link code has no explicit `no-store` | cross-cutting 7 | POST responses are not stored by shared caches without explicit freshness; the code is one-shot, expires in 10 minutes and needs the frontend user's fresh sign-in. |
| Stateful sessions run with the SDK's 2025-03-26 stream defaults; an `initialized` notification may land on another instance | T-19.7-11, T-19.7-15 | deferred-items.md (open). Both are availability details of multi-instance deployments; identity, binding and revocation checks are unaffected. The demo runs one instance. |
## Residual items
- Framework fixes c49983d, c29d228 and 1111828 are on framework master only: applications pinning summercms v0.4.4 (the demo release build, oauthserver v0.1.2, Journal v0.2.3) do not carry them until the next framework tag (v0.5.0 at phase completion). Recorded in deferred-items.md.
- The plugin test commits (U 72de822, O 2929c91, J a9a9b80) and the demo test commit (D 6fa2d56) are local and untagged; they change no shipped code.
- The real MCP client check against the demo (plan 04) is still a manual item.
## Suites run for this review
`bash scripts/check-phase19.7.sh --all` on a Docker host: self-test, go (framework `go vet` untagged and tagged, `go test -short ./...`, examples/hello, the tagged build, the required names untagged and tagged), plugins (vet, short tests and additive checks of the user, OAuth server and blog plugins), consumers (fonoteka.go, sm-bm-app, sm-demo-app untagged and tagged, the pin check, the deploy check), admin (typecheck, vitest, OpenAPI, dist), docs, marker, docker (cabana, user plugin, OAuth server and demo Postgres suites) all PASS.
## Gap closure (plans 06-09, 2026-10-23)
The deep code review after plan 05 (`19.7-REVIEW.md`, three lanes: A pier, B framework auth, cabana and the admin SPA, C the plugins and the demo) found 3 critical, 17 warning and 20 info findings. Plans 06-08 fixed every critical and warning finding and the info findings that were cheap; plan 09 added a named test for every fix, extended the gate and wrote this section. Two escalations from an application team rode along: E-1 (an organisation logo) and E-2 (plugin-declared scopes for member `usr_` tokens, decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`).
One production defect surfaced while plan 09 wrote its tests and was fixed in the user plugin: deleting an organisation left its avatar and logo attachment rows and blobs behind. 25b9f2e fixed it before the test commit; 38551a4 moved the cleanup to a new `FormAfterDelete` hook so that `FormBeforeDelete` keeps its declaration and the plugin stays additive. Pinned by `TestOrganisationLogo`.
### Corrections
Earlier verdicts of this review that the code review disproved. The register rows above carry the new status.
| Threat | Earlier verdict | Finding | What was wrong | Fix commit(s) | Pinning test |
|--------|-----------------|---------|----------------|---------------|--------------|
| T-19.7-22 | verified | C-CR-02 | The link proof compared the session token's `iat` with the request's `created_at`. A `POST /refresh` renews `iat`, so a browser signed in long before the request (or a stolen refresh-capable token) passed the "fresh sign-in" check after one refresh. The test that "verified" it never refreshed a token. | c14238b (F: `auth_time` at sign-in, copied by refresh, `bouncer.VerifyAuthTime`), 32f9259 (U: the confirm reads `auth_time`) | `TestBackendLinkAuthTime` (U, revert-checked), `TestAuthTime` (F) |
| cross-cutting 1 | link proof sound | C-CR-02 | Same defect, stated as a property of every link: the proof rested on `iat`. Rewritten in place above. | c14238b, 32f9259 | `TestBackendLinkAuthTime` |
| cross-cutting 6 | no log line carries the link code | C-WR-01 | The review stated that the link code stays out of logs. My account handed the code over in the query string of the site URL, so it reached nginx and site request logs, the browser history and the GET show route's access line. Rewritten in place above. | 32f9259 (U: `#code=` fragment, `POST /backend-link/show`, `no-store`), 0125e29 (V: the page reads the fragment and keeps the code in sessionStorage) | `TestBackendLinkShowPost` (U), `app/lib/linkCode.test.ts` (V) |
| T-19.7-23 | verified | C-WR-01 | The one-shot code was treated as secret while its transport leaked it (see cross-cutting 6); whoever read a log line could link their own signed-in account in the 10-minute window. | 32f9259, 0125e29 | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U) |
| T-19.7-03 | verified | A-WR-01, A-WR-02 | `Router.Handler` hands pier the compiled route chain, but the mux's path cleaning is not part of that chain: dispatched path values reached routes with `..`, `.` and empty segments, and an optional path field left `{key}` literal with an empty path value. | 104db99 (F) | `TestDispatchHardening`, `TestCatalogPathFields` (F) |
| T-19.7-17 | fixed in 0b8e027 (test pin; code unchanged) | A-WR-01, A-WR-02 | The same gap seen from argument injection: the schema check and the `/` refusal left traversal segments and missing path values through. | 104db99 | `TestDispatchHardening`, `TestCatalogPathFields` |
| T-19.7-18 | fixed in 0b8e027 (test pin; code unchanged) | A-CR-01, A-WR-03 | Stateful sessions whose record expired were never closed on the instance holding them (an unbounded server and goroutine leak; the idle TTL only removed the record), and `max_per_binding` was check-then-act, so concurrent initializes exceeded it. | 2bf9dca (reaper), c1c0c77 (atomic reservation) (F) | `TestSessionReaper` (revert-checked), `TestSessionReserve` (F) |
| T-19.7-08 | verified | B-WR-03 | Contributed text rendered as text, but an action's URL went into `href` and `window.open` unchecked: a `javascript:` URL would execute on the admin origin. | 44835e4 (cabana keeps http(s) and same-origin paths only), 88559d5 (SPA `safeUrl` on both sinks) (F) | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` |
| T-19.7-31 | verified | C-WR-02 | Consent showed the linked administrator, but the `backend` grant was bound to the user, not to that administrator: unlinking and linking to another administrator silently re-targeted a live grant. | 671513d (O: backend-scoped MCP tokens revoked on every link change, client-specific events), 970719c (U: the link events the cascade listens to) | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) |
| T-19.7-35 | fixed in 1111828 | C-CR-01 | Revocation was pinned for client revocation, deletion and deactivation only. A ban or a password reset left every MCP grant (and the linked administrator's reach) alive: the verifier and the refresh grant did not look at bans or `tokens_valid_after`. | 970719c (U: `AccessRevokedEvent` from ban, reset, change and admin-set password; banned users lose the link), 671513d (O: verifier and refresh refuse; MCP and OIDC grants revoked) | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) |
### Revert-checks
P-41: each critical finding's fix was reverted in a scratch worktree under the session scratch directory (`git worktree add --detach … HEAD`, then `git revert --no-commit <fix>`), the test files kept at HEAD, and the single pinning test run; the worktree was then removed (`git worktree remove --force`, `git worktree prune`). Nothing was committed from a worktree. At HEAD all three pass (gate run below).
| Repository | Fix | Test | With the fix reverted | At HEAD |
|------------|-----|------|-----------------------|---------|
| F `summercms.go` | 2bf9dca (A-CR-01, the session reaper) | `TestSessionReaper` | FAIL `reaper_test.go:79: reaped = 0, want 1` (memory store); the Redis case keeps B's copy past the expiry, the GET stream outlives its record, the store-error and `ephemeral` lines are missing. Worktree at ccf77b9; the revert's conflicts in `pier.go` and `export_test.go` were resolved keeping the later commits (the Redis warm-up of 2404572, the later test seams) and the `ReapNow` seam answers 0 because `reapOnce` is gone. | PASS |
| O `sm-oauthserver-plugin` | 671513d (C-CR-01, verifier and grant revocation) | `TestMCPVerifierRevocation` | FAIL `mcp_revocation_test.go:98: created two seconds before the cutoff: verify = <nil>, want pact.ErrMCPTokenInvalid`; all five subtests fail (same-second cutoff, untruncated `Before` "0 revoked, 2 live", banned user accepted, a deleted user's refresh answered 200). Worktree at 65f71dc with a scratch go.work (framework and user plugin by absolute path). | PASS |
| U `sm-user-plugin` | 32f9259 (C-CR-02, the confirm reads `auth_time`): the confirm handler only, restored to its pre-fix `iat` body | `TestBackendLinkAuthTime` | FAIL `backend_link_auth_time_test.go:64: confirm = 200 …, want 409 with "stale_session"` (login, request, refresh, confirm) and `:86` (a token without `auth_time` links). The framework's `auth_time` cannot be reverted from the plugin worktree, so only the plugin side was reverted. Worktree at 38551a4, `go mod edit -replace` to the framework's absolute path, `GOWORK=off`. | PASS |
### New threats
| Threat | Mitigation as built | Implementation | Test (repo) | Status |
|--------|---------------------|----------------|-------------|--------|
| T-19.7-42 | A reaper started at Mount closes every local session copy whose store record is gone, every min(idle_ttl/2, 1m); a store error keeps the copy; the GET stream re-check ends a stream whose record is gone; `expired` and `ephemeral` audit labels. | F `pier/session.go:501` (`reapOnce`), F `pier/pier.go:145` | `TestSessionReaper` (revert-checked), `TestPierReaperSmoke` (F) | fixed in 2bf9dca |
| T-19.7-43 | The per-binding slot is reserved atomically before the server is built (memory store under its lock, Redis in one Lua script) and released when connect or initialize fails; a store without the reservation falls back to Count then Put. | F `pier/session.go:145`, F `pier/store_memory.go:102`, F `pier/store_redis.go:165` | `TestSessionReserve` (50 concurrent initializes on memory and on miniredis across two instances) (F) | fixed in c1c0c77 |
| T-19.7-44 | Path values the mux never delivers (`..`, `.`, empty segments, `/` in one segment) and missing path values are refused before dispatch; a path field must be required at boot; an embedded struct on a GET or DELETE tool fails boot. | F `pier/dispatch.go:126`, `:146`; F `pier/catalog.go:223`, `:251` | `TestDispatchHardening`, `TestCatalogPathFields` (F) | fixed in 104db99 |
| T-19.7-45 | A provider or database error answers the fixed text `pier: the tool could not be run for this caller`; the error is logged server-side only. | F `pier/dispatch.go:30`, `:187` | `TestDispatchHardening` "a provider error answers the fixed text and is only logged" (F) | fixed in 104db99 |
| T-19.7-46 | A personal-token subject without a TokenID is refused (401), so no two users share `pat:user:0`. | F `pier/identity.go:270` | `TestPersonalTokenBinding` (F) | fixed in 2404572 |
| T-19.7-47 | The Redis store is opened and subscribed at Mount (warm-up with backoff) and kicked by every request; a `subscriptions/listen` stream re-checks its bearer; an LRU eviction closes the evicted server's streams. | F `pier/store_redis.go:279`; F `pier/stateless.go:116`, `:166` | `TestStatelessFanOut` (F) | fixed in 2404572 |
| T-19.7-48 | Servers are built and connected outside the router-wide locks and inserted with a re-check, so one slow guard check stalls no other session. | F `pier/session.go:174`; F `pier/stateless.go:137` | `TestLockScope` (call, DELETE and stateless request finish while an initialize blocks in a guard), `go test -race ./modules/pier` (F) | fixed in c1c0c77 |
| T-19.7-49 | A dispatch whose guard list or principal kind contradicts the tool's guard is refused with the fixed text and logged. | F `pier/dispatch.go:198` | `TestDispatchHardening` "a dispatch contradicting the tool's guard is refused" (F) | fixed in 104db99 |
| T-19.7-50 | `pact.MCPPrompt.Writes` puts a prompt under the read/write ceiling; Journal's `draft_post` declares it. | F `pier/filter.go:52`, F `pact/mcp.go:86`; J `mcp.go:162` | `TestPromptWriteCeiling` (F), `TestJournalPromptWrites` (J), `TestMCPRevocationEndToEnd` (c) (D) | fixed in 211cec1 (F), b129902 (J) |
| T-19.7-51 | `auth_time` (OIDC name) is stamped equal to `iat` at every sign-in mint and copied unchanged by every refresh path; a token without it refreshes without it; `bouncer.VerifyAuthTime` keeps the signature, audience and expiry checks; the link confirm reads it (P-29; the user decision that sessions issued before the upgrade sign in again before linking, 19.7-GAP-INPUTS.md). | F `bouncer/mint.go:45`, `bouncer/refresh.go:92`, `bouncer/jwt.go:214`; U `controllers/backend_link_controller.go:102` | `TestAuthTime`, `TestAuthTimeSmoke` (F), `TestBackendLinkAuthTime` (U, revert-checked) | fixed in c14238b (F), 32f9259 (U) |
| T-19.7-52 | Member token scopes are a separate capability, `pact.HasFrontendTokenScopes`, which cabana's admin catalog never reads; both catalogs share one name rule, `pact.ValidTokenScopeName` (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`, P-30). | F `pact/capabilities.go:676`, `:687`; F `cabana/token_scopes.go:107` | `TestFrontendTokenScopes`, `TestTokenScopeNameParity` (F), `TestMemberTokenScopesBoot` (U) | fixed in 9eedfbb |
| T-19.7-53 | cabana keeps an account action URL only for http(s) with a host or a same-origin path (`//` and `/\` refused) and logs a refusal without the URL; the SPA applies `safeUrl` to `href` and `window.open`. | F `cabana/account_contributions.go:217`, `:233`; F `admin/src/components/account/AccountContributions.vue:31` | `TestAccountContributionURL` (F), `admin/tests/account/AccountContributions.test.ts` "a javascript: URL answer renders no link" | fixed in 44835e4, 88559d5 |
| T-19.7-54 | A revoked event names the token (TokenID) or the OAuth client (ClientID) and pier ends only the matching sessions; cabana's token revoke, the user plugin's token delete and the authorized-apps revoke fire the narrowed events. | F `pier/events.go:79`, F `cabana/subject_events.go:51`; U `controllers/api_tokens.go:142`; O `controllers/authorizations.go:124` | `TestTokenScopedRevoke` (across two instances), `TestAccountTokenRevokeEvent` (F), `TestAPITokenRevokeEvent` (U), `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (d) (D) | fixed in 24cf6cd (F), 970719c (U), 671513d (O) |
| T-19.7-55 | pier puts the client address on the resolve context (surf's trusted-proxy answer, else the RemoteAddr host); `ResolveBackendToken` logs it as `remote` on every `admin.auth` line. | F `towel/context.go:70`, F `pier/pier.go:254`, F `cabana/token_resolver.go:29` | `TestClientIP`, `TestResolveClientAddress`, `TestResolveBackendTokenRemote` (F) | fixed in 24cf6cd |
| T-19.7-56 | A failing Status marks only its section `error: true`, a failing Applies drops only that action, the list answers 200; the SPA shows the section error line and a toast for a failed GET, keeping the earlier sections. | F `cabana/account_contributions.go:119`; F `admin/src/components/account/AccountContributions.vue:125` | `TestAccountContributionsIsolation` (F), `admin/tests/account/AccountContributions.test.ts` (section error, 500 on reload) | fixed in 44835e4, 88559d5 |
| T-19.7-57 | A ban, a password reset, an administrator setting a password and the user's own change fire `AccessRevokedEvent` in the write's transaction (ban: newly banned users only) and a revoked subject event per user after the commit; the OAuth server revokes MCP and OIDC tokens created before the untruncated `Before`, deletes consents and unused codes; the verifier and the MCP refresh grant refuse banned users and tokens created before `tokens_valid_after + 1s`; `LinkedBackendUser` excludes banned users (user decision 2026-10-23: both MCP and OIDC grants end). | O `classes/mcp_verifier.go:96`, `:99`, `:139`; O `classes/mcp_store.go:281`, `:308`; O `classes/cascade.go:95`, `:161`; U `classes/access_revoked.go:34`, `classes/backend_links.go:423`; U `controllers/users_admin_controller.go:253`, `:651`; U `controllers/api_controller.go:463`, `:732` | `TestMCPVerifierRevocation` (O, revert-checked), `TestMCPGrantRevocation`, `TestMCPRevocationSmoke` (O), `TestAccessRevocation` (U), `TestMCPRevocationEndToEnd` (a) (D) | fixed in 970719c (U), 671513d (O) |
| T-19.7-58 | The link code travels in the `#code=` fragment and the body of `POST /backend-link/show` (`no-store, private`), is kept in sessionStorage across the sign-in and removed from the URL; the page shows the signed-in account before the button; the GET show route stays for compatibility, documented as deprecated. | U `controllers/backend_link_controller.go:45`, `:75`; U `account_contributions.go:120`; V `app/lib/linkCode.ts:20`; V `app/pages/link-admin.vue:55` | `TestBackendLinkShowPost`, `TestBackendLinkAuthTimeSmoke` (U), `app/lib/linkCode.test.ts` (V) | fixed in 32f9259 (U), 0125e29 (V) |
| T-19.7-59 | Every link and unlink revokes the user's `backend`-scoped MCP tokens (only those) and fires a revoked event per affected client after the commit, so a grant never acts as an administrator the user did not consent to. | O `classes/cascade.go:175`, `:184`, `:198` | `TestMCPGrantRevocation` (O), `TestMCPRevocationEndToEnd` (b), `TestMCPEndToEnd` "a link change ends the backend grant" (D) | fixed in 671513d |
| T-19.7-60 | The member token scope catalog is compiled at boot from the activated plugins (pattern, built-ins reserved, duplicates name both plugins, labels and phrase keys checked); `POST /tokens` and `MintAPITokenFor` check the application's own catalog, so another application's declaration answers 422; `middleware.Scope` checks declared scopes like built-ins; the legacy `MintAPIToken` uses a documented process-wide fallback (decision note `.planning/notes/2026-10-23-frontend-token-scopes.md`; E-2's acceptance list, 19.7-GAP-INPUTS.md). | U `classes/api_token_scopes.go:87`, `classes/api_tokens.go:66`, `plugin.go:126`, `controllers/api_tokens.go:57` | `TestMemberTokenScopes` (E-2's acceptance list item by item), `TestMemberTokenScopesBoot`, `TestMemberTokenScopesSmoke` (U) | fixed in ee5c096 |
| T-19.7-61 | The organisation logo is an image-mode fileupload through lagoon/attach (the avatar's validation), behind `golem15.users.access_users`; deleting an organisation removes its avatar and logo attachments. | U `models/organisation.go:44`, `models/organisation/fields.yaml:25`, `controllers/organisations_admin_controller.go:130` | `TestOrganisationLogo` (U) | fixed in f20a8d3, 25b9f2e, 38551a4 |
| T-19.7-62 | nginx's `location = /mcp` allows 4 MiB bodies, pier's own cap, enforced by the deploy check. | D `deploy/nginx/demo.summercms.io.conf:217`, D `scripts/check-deploy.sh:254` | gate stage `consumers` (`check-deploy.sh`) (D) | fixed in 3b569a1 |
| T-19.7-63 | `MCPResolvePersonalToken` stamps `last_used_ip` from `towel.ClientIP` and leaves it unchanged without an address. | U `classes/mcp_guard.go:174` | `TestUserMCPGuardClientIP` (U) | fixed in 970719c |
| T-19.7-64 | The gate's workspace check refuses a repository resolving the framework (or, for the OAuth server plugin, the user plugin) anywhere but the expected checkout; the unreleased user plugin is built with fonoteka.go and sm-bm-app through scratch copies of their go.work; a demo submodule ahead of its pin is accepted only by test-only commits or by commits its canonical checkout holds, and reported; `PHASE197_RELEASED=1` refuses any drift. | F `scripts/check-phase19.7.sh:432` (`workspace_verdict`), `:446` (`workspace_check`), `:515` (`consumer_with_user_plugin`), `:946` (`demo_pins`), `:957` (released mode) | `--self-test` (match, symlinked match, mismatch naming both paths, empty answer; the planted go.work rewrite; test-only, unreleased accepted, unreleased refused and both released-mode refusals), `PHASE197_RELEASED=1 --consumers` exits 1 while the pins drift (F) | fixed in 0aa48ae |
| T-19.7-65 | A high or critical control is verified only with a test that fails without it (Method step 6); the three critical findings are revert-checked; every disproved verdict is listed under Corrections and its register row carries the corrected status. | this section | the Revert-checks table; `bash scripts/check-phase19.7.sh --all` (F) | verified |
| T-19.7-SC (gap closure) | No Go module or npm package was added in plans 06-09: go.mod, go.sum and the npm locks are unchanged in every repository (framework, the three plugins, the demo, vue-demo-app); vitest runs use the committed locks. | `git diff` of go.mod, go.sum, admin/package-lock.json and pnpm-lock.yaml over plans 06-09 | gate stage `admin` (`npm --prefix admin` with the committed lock), plan 09 check (F, U, O, J, D, V) | verified |
### Suites run for the gap closure
`bash scripts/check-phase19.7.sh --all` on a Docker host after the plan 09 commits: self-test, go, plugins, consumers, admin, docs, marker and docker all PASS, "Phase 19.7 gate passed" (the run is recorded in 19.7-09-SUMMARY.md). The consumers stage reports `plugins/golem15/journal`, `oauthserver` and `user` ahead of their pins by unreleased commits, which is expected until the release (deferred-items.md).

View File

@@ -0,0 +1,135 @@
---
phase: "19.7"
slug: "generic-mcp-module"
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-23"
---
# Phase 19.7 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail. The evidence (file and line, pinning test) for every threat is in `19.7-SECURITY-REVIEW.md`.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| MCP client to `/mcp` | any agent on the internet presents a bearer, a session id and JSON-RPC bodies | OAuth access token or personal token, session id, tool arguments (high) |
| pier to plugin routes (in-process dispatch) | pier calls a plugin's compiled route chain as the caller's pre-resolved principal | dispatch marker with a principal, arguments as path, query and body (high) |
| frontend user to the link confirm and consent pages | a signed-in browser confirms a link code or decides a consent | session JWT, link code, consent decision (high) |
| administrator to My account and the Administrators screen | admin actions that start, set or remove a link | admin JWT, prompted email value (high) |
| verifier and resolvers to pier | the OAuth server plugin and the user plugin answer who a bearer is and which admin a user is linked to | grants, subjects, link answers (high) |
| instances to the shared session store | Redis holds session records and carries subject events between instances | binding keys, protocol state, events (medium) |
| nginx to the Go binary | the public vhost opens the MCP paths only | streamed MCP traffic (medium) |
| test results to the phase verdict | the gate's output decides whether the phase is verified | test output (medium) |
---
## Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|-----------|----------|-----------|----------|-------------|------------|--------|
| T-19.7-01 | Spoofing | bouncer dispatch marker | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit c49983d | closed |
| T-19.7-02 | Elevation of privilege | cabana ResolveBackend and backend middleware with a marker | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit c49983d | closed |
| T-19.7-03 | Elevation of privilege | surf Handler lookup | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 104db99 (was verified; A-WR-01, A-WR-02) | closed |
| T-19.7-04 | Tampering | streaming route recovery | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-05 | Denial of service | mcp.enabled without the tag | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-06 | Elevation of privilege | account contribution actions | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-07 | Tampering | prompted record action value | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-08 | Information disclosure | account contribution rendering in the SPA | low | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 44835e4, 88559d5 (was verified; B-WR-03) | closed |
| T-19.7-09 | Elevation of privilege | wristband MCP scope allow-list | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-10 | Spoofing | bearer verification | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-11 | Spoofing | session hijack with a stolen Mcp-Session-Id | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-12 | Elevation of privilege | token passthrough / confused deputy | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-13 | Elevation of privilege | scope escalation to backend tools | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-14 | Information disclosure | tool-list leakage across principals | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-15 | Elevation of privilege | rehydration of a revoked session | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 1111828 | closed |
| T-19.7-16 | Spoofing | audience confusion at /mcp | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-17 | Tampering | argument injection into routes | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 104db99 (was a test pin only; A-WR-01, A-WR-02) | closed |
| T-19.7-18 | Denial of service | sessions, streams and large results | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 2bf9dca, c1c0c77 (was a test pin only; A-CR-01, A-WR-03) | closed |
| T-19.7-19 | Spoofing | DNS rebinding / browser Origin | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 0b8e027 (test pin; code unchanged) | closed |
| T-19.7-20 | Information disclosure | audit log contents | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-21 | Tampering | prompt injection through tool metadata | low | accept | 19.7-SECURITY-REVIEW.md: accepted (static plugin text, capped) | closed |
| T-19.7-SC | Tampering | go get of go-sdk and jsonschema-go | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-22 | Elevation of privilege | link without proof | high | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in c14238b (F), 32f9259 (U) (was verified; C-CR-02, revert-checked) | closed |
| T-19.7-23 | Spoofing | replayed or guessed link code | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 32f9259 (U), 0125e29 (V) (was verified; C-WR-01) | closed |
| T-19.7-24 | Elevation of privilege | admin sets a link | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-25 | Elevation of privilege | cross-site confirm or unlink | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-26 | Information disclosure | stale link resolution | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-27 | Elevation of privilege | list and call disagree on frontend permissions | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-28 | Spoofing | personal token resolution at /mcp | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-29 | Repudiation | who linked whom | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-30 | Spoofing | audience confusion at the verifier | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-31 | Elevation of privilege | consent phishing for the backend scope | high | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 671513d (O), 970719c (U) (was verified; C-WR-02) | closed |
| T-19.7-32 | Elevation of privilege | consent decided for another user's request | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-33 | Elevation of privilege | submitted scopes beyond the request | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-34 | Spoofing | superuser reach through an agent (D-22) | high | mitigate | 19.7-SECURITY-REVIEW.md: accepted as designed with audit and ceiling (no switch) | closed |
| T-19.7-35 | Information disclosure | revoked grant keeps a live session | medium | mitigate | 19.7-SECURITY-REVIEW.md: corrected, fixed in 970719c (U), 671513d (O) (was fixed in 1111828; C-CR-01, revert-checked); missing self-service revoke accepted | closed |
| T-19.7-36 | Tampering | partial update tools zeroing fields | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-37 | Denial of service | public /mcp and SSE through nginx | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in commit 0b8e027 (gate pin; config unchanged) | closed |
| T-19.7-38 | Spoofing | open redirect from the consent page | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-39 | Repudiation | a green gate that measured nothing | high | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-40 | Tampering | the marker rule eroding over time | medium | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-41 | Information disclosure | test fixtures leaking credentials | low | mitigate | 19.7-SECURITY-REVIEW.md: verified | closed |
| T-19.7-42 | Denial of service | abandoned stateful sessions | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2bf9dca (revert-checked) | closed |
| T-19.7-43 | Denial of service | concurrent initialize past max_per_binding | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c1c0c77 | closed |
| T-19.7-44 | Tampering | path values the mux never delivers | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-45 | Information disclosure | raw provider or database errors in tool results | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-46 | Spoofing | personal-token sessions sharing pat:user:0 | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2404572 | closed |
| T-19.7-47 | Elevation of privilege | stateless streams and stateless-only instances outliving a revocation | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 2404572 | closed |
| T-19.7-48 | Denial of service | router-wide locks across guard and database I/O | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c1c0c77 | closed |
| T-19.7-49 | Elevation of privilege | a provider naming the backend guard for a frontend tool | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 104db99 | closed |
| T-19.7-50 | Information disclosure | a write-directing prompt for a read-only grant | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 211cec1 (F), b129902 (J) | closed |
| T-19.7-51 | Spoofing | the fresh-sign-in proof (auth_time) | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in c14238b (F), 32f9259 (U) (revert-checked) | closed |
| T-19.7-52 | Elevation of privilege | member scopes crossing into the admin token catalog | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 9eedfbb | closed |
| T-19.7-53 | Tampering | javascript: or data: URL from an account action | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 44835e4, 88559d5 | closed |
| T-19.7-54 | Denial of service | one token revoke ending every MCP session | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 24cf6cd (F), 970719c (U), 671513d (O) | closed |
| T-19.7-55 | Repudiation | failed g15_ attempts without a client address | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 24cf6cd | closed |
| T-19.7-56 | Information disclosure | one plugin's error hiding every My account section | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 44835e4, 88559d5 | closed |
| T-19.7-57 | Elevation of privilege | banned or password-reset users keeping MCP grants | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 970719c (U), 671513d (O) (revert-checked) | closed |
| T-19.7-58 | Spoofing | the link code in access logs and browser history | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 32f9259 (U), 0125e29 (V) | closed |
| T-19.7-59 | Elevation of privilege | a backend grant re-targeted to another administrator | high | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 671513d | closed |
| T-19.7-60 | Elevation of privilege | member tokens with undeclared or shadowing scopes | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in ee5c096 | closed |
| T-19.7-61 | Tampering | organisation logo uploads | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in f20a8d3, 25b9f2e, 38551a4 | closed |
| T-19.7-62 | Denial of service | nginx 2 MiB cap below pier's 4 MiB | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 3b569a1 | closed |
| T-19.7-63 | Repudiation | stale last_used_ip of personal tokens at /mcp | low | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 970719c | closed |
| T-19.7-64 | Repudiation | a green gate on a stray workspace or unreleased drift | medium | mitigate | 19.7-SECURITY-REVIEW.md: fixed in 0aa48ae | closed |
| T-19.7-65 | Repudiation | a control marked verified without a test that fails without it | high | mitigate | 19.7-SECURITY-REVIEW.md: verified (revert-checks, Method step 6, Corrections) | closed |
*Status: open · closed. Every high threat is verified or fixed; the accepted items are listed below with their reasons. Rows marked "corrected" had a verdict the phase code review disproved; the Corrections table of 19.7-SECURITY-REVIEW.md says what was wrong.*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-19.7-01 | T-19.7-21 | Tool and prompt descriptions are static plugin text, capped at 1024 runes at boot; dynamic text is a plugin bug. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-02 | T-19.7-34 | A linked superuser's agent reaches only the declared backend tools, behind an explicit `backend` consent with the admin's name, the read/write ceiling and a per-call audit line; a refusal switch would block the solo-owner case while a non-superuser link already gives the narrower alternative. Revisit when a plugin declares a superuser-only or destructive backend tool beyond content editing. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-03 | T-19.7-35 | MCP grants have no consent row, so the authorized-apps list does not revoke them yet; access still ends through client revocation, user deletion or deactivation, token expiry and the verifier's refusal of a revoked token. Follow-up in deferred-items.md. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-04 | cross-cutting `no-store` | The POST answer of My account's link action carries a one-shot, 10-minute link code without explicit `no-store`; POST responses are not stored by shared caches without explicit freshness. | plan 19.7-05 security review | 2026-10-23 |
| AR-19.7-05 | T-19.7-11, T-19.7-15 | Stateful sessions use the SDK's 2025-03-26 stream defaults, and an `initialized` notification may land on another instance (deferred-items.md); multi-instance availability details that leave identity, binding and revocation checks unchanged. | plan 19.7-05 security review | 2026-10-23 |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-10-23 | 42 | 42 | 0 | plan 19.7-05 executor (Task 4), gate `bash scripts/check-phase19.7.sh --all` green |
| 2026-10-23 | 66 | 66 | 0 | plan 19.7-09 executor (Task 4), gap closure: 8 verdicts corrected after the code review, T-19.7-42..T-19.7-65 added, the three critical fixes revert-checked, gate `bash scripts/check-phase19.7.sh --all` green |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] No open threat (frontmatter count confirmed)
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-10-23; re-verified after the gap closure (plans 06-09) 2026-10-23

View File

@@ -0,0 +1,48 @@
---
status: complete
phase: 19.7-generic-mcp-module
source: [19.7-VERIFICATION.md]
started: 2026-10-23T20:05:22.930Z
updated: 2026-10-23T22:31:27Z
---
## Current Test
[testing complete]
## Tests
### 1. Decide on SC-3's wording: frontend tool filtering uses group grants (what user.permission enforces), not MergedPermissions (groups plus the user's own allow/deny). Accept the override below, or ask for MergedPermissions in both the route middleware and the MCP guard (a behaviour change of a core plugin).
expected: An override entry is added to this frontmatter (suggested YAML in the report), or a follow-up is opened
result: pass
note: override accepted in 19.7-VERIFICATION.md (SC-3 group-grant wording)
### 2. Real MCP client against the local demo (DEPLOY.md, 'MCP endpoint', 'Locally'): add http://127.0.0.1:8097/mcp to Claude Code or Claude Desktop, sign in on /connect as a frontend user asking for backend, see only user_me; in the admin's My account start 'Link frontend account', finish on /link-admin with a fresh sign-in, reconnect (consent shows 'also act as admin <name>'), see the journal_ tools, create a draft post and find it in the admin; unlink and see the journal_ tools disappear.
expected: One connector, one OAuth sign-in; journal_ tools appear only with a verified link plus the backend scope and disappear after unlinking (the session ends, a new session lists user_me only)
result: pass
note: real client: Claude Code against the local demo on the Nuxt origin (127.0.0.1:3001, app.url and both MCP resources overridden as DEPLOY.md 'Locally' describes); frontend test user uat@example.test
### 3. Consent page with a changed link, in a browser: open /connect for a request asking for backend, then change the user's link to another administrator (Administrators screen) before clicking Allow.
expected: The decision answers 409 backend_link_changed, the page reloads the request and shows the linkChanged notice naming the new administrator; no grant is issued for the old one
result: pass
### 4. Link page 'Signed in as', in a browser: start 'Link frontend account' on My account, open the #code=... URL while signed in as a different frontend user than intended.
expected: /link-admin shows the request and 'Signed in as <email>' before the confirm button, keeps the code across a sign-in (sessionStorage), and a session older than the request gets the stale-session message
result: pass
### 5. RB-WR-01 in Firefox: in the admin, on a record form with a plugin field type that renders inside a shadow root (taglist or another widget), type into the inner input and press Ctrl+S (and Ctrl+Enter) with a pending taglist draft.
expected: The pending draft is committed before the save; plain keys typed inside the shadow root (letters, Esc, /, ?) trigger no shortcut
result: pass
## Summary
total: 5
passed: 5
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps
[none]

View File

@@ -3,9 +3,9 @@ phase: "19.7"
slug: "generic-mcp-module"
# 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
status: validated
nyquist_compliant: true
wave_0_complete: true
created: "2026-10-21"
---
@@ -31,7 +31,7 @@ created: "2026-10-21"
- **After every task commit:** quick run command for the touched module(s)
- **After every plan wave:** framework `go vet ./... && go test ./...` plus the touched plugin suites
- **Before `/msd-verify-work`:** `scripts/check-phase19.7.sh` green
- **Before `/msd-verify-work`:** `bash scripts/check-phase19.7.sh --all` green on a Docker host (every stage PASS, no NOT RUN docker)
- **Max feedback latency:** 120 seconds
---
@@ -42,33 +42,84 @@ Filled in by the planner per task; the behaviour → test map is:
| Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|-----|----------|-----------|-------------------|-------------|--------|
| SC-1/D-30 | disabled → nothing mounted; enabled → `/mcp` mounted | unit | `go test -short ./modules/surf -run TestPierMount` | ❌ W0 | ⬜ pending |
| SC-1 | SSE stream flushes through the streaming route kind | unit | `go test -short ./modules/surf -run TestStreamingRoute` | ❌ W0 | ⬜ pending |
| SC-2/D-10..D-12 | binding/duplicate/name boot errors; prefixing | unit | `go test -short ./modules/pier -run TestCatalog` | ❌ W0 | ⬜ pending |
| D-11/D-13/D-14 | arg split, isError with route body, annotations | unit | `go test -short ./modules/pier -run TestDispatch` | ❌ W0 | ⬜ pending |
| D-15/D-16 | non-token routes refused; requirement union | unit | `go test -short ./modules/pier -run TestGuardRules` | ❌ W0 | ⬜ pending |
| D-17 | marker honoured in-process only; not forgeable from HTTP; frontend marker refused by backend guard | unit | `go test -short ./modules/bouncer ./modules/cabana -run TestDispatchMarker` | ❌ W0 | ⬜ pending |
| SC-3/D-20/D-23 | per-principal tools/list; read grant hides write tools | integration | `go test -short ./modules/pier -run TestToolsList` | ❌ W0 | ⬜ pending |
| D-25 | session binding, DELETE, cross-instance rehydration, TTL | integration | `go test -short ./modules/pier -run TestSessions` | ❌ W0 | ⬜ pending |
| D-26/D-27 | pub/sub list_changed; revocation ends session | integration | `go test -short ./modules/pier -run TestFanOut` | ❌ W0 | ⬜ pending |
| D-09 | plugin frontend permission codes in editors | Postgres | user plugin `go test ./... -run TestFrontendPermissionCatalog` | ❌ W0 | ⬜ pending |
| D-03..D-08 | link proof, one-shot, 1:1, admin set, per-request lookup | Postgres | user plugin `go test ./... -run TestBackendLink` | ❌ W0 | ⬜ pending |
| D-04 | account contribution seam; existing payloads unchanged | unit + vitest | `go test -short ./modules/cabana -run TestAccountContributions`; `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
| D-21 | `backend` scope and consent line | unit + Postgres | `go test -short ./modules/wristband -run TestMCPScopes`; plugin `-run TestMCPConsent` | ❌ W0 | ⬜ pending |
| SC-4 | `oas_at_` verifier refuses OIDC/revoked/expired/foreign tokens | Postgres | oauthserver plugin `go test ./... -run TestMCPTokenVerifier` | ❌ W0 | ⬜ pending |
| SC-4/D-18 | end-to-end in sm-demo-app incl. unlink removes tools | e2e | `cd ../sm-demo-app && go test ./... -run TestMCPEndToEnd` | ❌ W0 | ⬜ pending |
| D-31 | consumers build and pass at bumped pins | build | `scripts/check-phase19.7.sh` stage `consumers` | ❌ W0 | ⬜ pending |
| SC-1/D-30/D-33 | disabled → nothing mounted; enabled without the tag → boot error; tagged → `/mcp` stream routes and PRM documents; collision names the owner | unit | `go test -short ./modules/surf -run '^TestPierMount$'` and `go test -short -tags summercms_mcp ./modules/surf -run '^TestPierMount$'` | ✅ `modules/surf/pier_mount_test.go` | ✅ green |
| SC-1 | SSE stream flushes through the streaming route kind; throttle, CORS profile and access-log pattern kept; buffered Flush a no-op | unit | `go test -short ./modules/surf -run '^TestStreamingRoute$'` | ✅ `modules/surf/streaming_test.go` | ✅ green |
| SC-2/D-10..D-12/D-14 | binding/duplicate/name boot errors; prefixing; annotations by method | unit | `go test -short ./modules/pier -run '^TestCatalog$'` | ✅ `modules/pier/catalog_test.go` | ✅ green |
| D-11/D-13 | arg split, omitted fields absent, no outer credentials, isError with the route body, `{"data":…}`, size cap, call timeout | unit | `go test -short ./modules/pier -run '^TestDispatch$'` | ✅ `modules/pier/dispatch_test.go` | ✅ green |
| D-15/D-16/D-22 | non-token routes refused; requirement union; method-aware provider; admin token scope per method | unit | `go test -short ./modules/pier -run '^TestGuardRules$'`; `go test -short ./modules/cabana -run '^TestScopeLimitForMethods$'` | ✅ `modules/pier/guard_rules_test.go`, `modules/cabana/scope_limit_for_test.go` | ✅ green |
| D-17 | marker honoured in-process only; not forgeable from HTTP; frontend marker refused by the backend guard and `ResolveBackend` | unit + Postgres | `go test -short ./modules/bouncer ./modules/cabana -run '^TestDispatchMarker$'`; Postgres part `go test ./modules/cabana -run '^TestDispatchMarker$'`; gate stage `marker` | ✅ `modules/bouncer/dispatch_test.go`, `modules/cabana/dispatch_marker_test.go` | ✅ green |
| SC-3/D-20/D-23 | per-principal tools/list; read grant hides write tools; no subject, no tool; unlisted call never reaches the route; union of guards | integration | `go test -short ./modules/pier -run '^TestToolsList$'` | ✅ `modules/pier/tools_list_test.go` | ✅ green |
| D-25 | session binding, DELETE, cross-instance rehydration, TTL, per-binding cap (Redis and memory) | integration | `go test -short ./modules/pier -run '^TestSessions$'` | ✅ `modules/pier/sessions_test.go` | ✅ green |
| D-26/D-27 | pub/sub list_changed; link change and cache bypass; revocation and deletion end sessions and evict stateless servers | integration | `go test -short ./modules/pier -run '^TestFanOut$'` | ✅ `modules/pier/fanout_test.go` | ✅ green |
| D-09 | plugin frontend permission codes in editors; duplicates dropped; saved codes granted | Postgres | user plugin `GOWORK=off go test . -run '^TestFrontendPermissionCatalog$'` | ✅ `plugins/golem15/user/frontend_permission_catalog_test.go` | ✅ green |
| D-03..D-08 | link proof, one-shot, fresh sign-in at the second, 1:1, admin set, events, per-request lookup | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLink$'` | ✅ `plugins/golem15/user/backend_link_test.go` | ✅ green |
| D-15/D-35 | user guard: parser refusals, deleted user, expired token, dispatch credential against `user.scope` | Postgres | user plugin `GOWORK=off go test . -run '^TestUserMCPGuard$'` | ✅ `plugins/golem15/user/mcp_guard_test.go` | ✅ green |
| D-04 | account contribution seam; Applies and permission filtering; 409; interactive only; transaction; existing payloads unchanged | unit + Postgres + vitest | `go test -short ./modules/cabana -run '^TestAccountContributions$'` (Postgres part without `-short`); `npm --prefix admin run test`; gate check `account.go` unchanged since 51d088a | ✅ `modules/cabana/account_contributions_test.go`, `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| D-34 | prompted record actions: localized prompt, trim and cap, refusals, empty Value for a plain action | unit + Postgres + vitest | `go test -short ./modules/cabana -run '^TestRecordActionPrompt$'`; `go test ./modules/cabana -run '^TestRecordActionPromptRun$'`; `npm --prefix admin run test` | ✅ `modules/cabana/record_action_prompt_test.go`, `admin/tests/form/RecordActionsPrompt.test.ts` | ✅ green |
| D-21 | `backend` scope and consent line | unit + Postgres | `go test -short ./modules/wristband -run '^TestMCPScopes$'`; oauthserver plugin `go test . -run '^TestMCPConsent$'` | ✅ `modules/wristband/mcp_scopes_test.go`, `sm-oauthserver-plugin/mcp_consent_test.go` | ✅ green |
| SC-4 | `oas_at_` verifier refuses OIDC/revoked/expired/foreign/inactive-client/deleted-user tokens; conditional stamp | Postgres | oauthserver plugin `go test . -run '^TestMCPTokenVerifier$'` | ✅ `sm-oauthserver-plugin/mcp_verifier_test.go` | ✅ green |
| D-15/D-16 | Journal tools compile through pier; input keys are the routes' keys; prompt | unit | blog plugin `go test -short . -run '^TestJournalMCP$'` | ✅ `sm-journal-plugin/mcp_test.go` | ✅ green |
| SC-4/D-08/D-18/D-20/D-26 | end-to-end in sm-demo-app incl. unlink list_changed, read grant, revoked grant, personal tokens off | e2e | `cd ../sm-demo-app && go test -tags summercms_mcp . -run '^TestMCPEndToEnd$'` | ✅ `sm-demo-app/mcp_e2e_test.go` | ✅ green |
| D-31 | plugins additive against their pre-phase commits; consumers build and pass at bumped pins | build | `bash scripts/check-phase19.7.sh --plugins` and `--consumers` | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| A-CR-01/A-IN-01/A-IN-06 | reaper closes expired copies (memory, Redis per instance), store error keeps the copy, GET stream ends when its record is gone, `ephemeral` label | integration | `go test -short ./modules/pier -run '^TestSessionReaper$'` (also `-tags summercms_mcp`) | ✅ `modules/pier/reaper_test.go` | ✅ green |
| A-WR-03 | 50 concurrent initializes open exactly `max_per_binding` (memory, two instances on miniredis); a failed initialize releases its slot; Count/Put fallback | integration | `go test -short ./modules/pier -run '^TestSessionReserve$'` | ✅ `modules/pier/reserve_test.go` | ✅ green |
| A-WR-07 | a call, a DELETE and a stateless request finish while an initialize blocks in a guard check | integration | `go test -short -race ./modules/pier -run '^TestLockScope$'` | ✅ `modules/pier/lock_scope_test.go` | ✅ green |
| A-WR-04 | a returning binding with read write, or with a backend subject, lists the new tools on both paths; narrowing hides them | integration | `go test -short ./modules/pier -run '^TestScopeWidening$'` | ✅ `modules/pier/widening_test.go` | ✅ green |
| A-WR-05/A-WR-06 | a stateless-only Redis instance applies a revocation; a listen stream ends with its bearer; LRU eviction closes the stream | integration | `go test -short ./modules/pier -run '^TestStatelessFanOut$'` | ✅ `modules/pier/stateless_fanout_test.go` | ✅ green |
| A-WR-09 | a subject without TokenID is 401; two users' personal tokens get distinct bindings | integration | `go test -short ./modules/pier -run '^TestPersonalTokenBinding$'` | ✅ `modules/pier/personal_binding_test.go` | ✅ green |
| A-WR-01/A-WR-02/A-WR-08/A-IN-04/A-IN-05/A-IN-08 | traversal and empty segments refused before the route; missing path value refused; fixed provider error text; large integers keep their digits; outer Host and forwarding headers; contradicting dispatch refused | unit | `go test -short ./modules/pier -run '^TestDispatchHardening$'` | ✅ `modules/pier/dispatch_hardening_test.go` | ✅ green |
| A-WR-02/A-IN-07 | an omitempty path field and an embedded struct on a GET tool fail boot | unit | `go test -short ./modules/pier -run '^TestCatalogPathFields$'` | ✅ `modules/pier/catalog_path_fields_test.go` | ✅ green |
| C-IN-01 | a `Writes` prompt is listed only for read and write grants | integration | `go test -short ./modules/pier -run '^TestPromptWriteCeiling$'`; blog plugin `go test -short . -run '^TestJournalPromptWrites$'` | ✅ `modules/pier/prompt_ceiling_test.go`, `sm-journal-plugin/mcp_prompt_writes_test.go` | ✅ green |
| B-WR-04/C-IN-02 | a TokenID event ends only that token's sessions, a ClientID event only that client's, neither all; through Redis between instances | integration | `go test -short ./modules/pier -run '^TestTokenScopedRevoke$'` | ✅ `modules/pier/token_events_test.go` | ✅ green |
| B-IN-04 | a resolver sees `towel.ClientIP` (RemoteAddr host, or the host's trusted-proxy answer); towel round trip; `ResolveBackendToken` logs `remote` | unit + Postgres | `go test -short ./modules/pier -run '^TestResolveClientAddress$'`; `go test -short ./modules/towel -run '^TestClientIP$'`; `go test ./modules/cabana -run '^TestResolveBackendTokenRemote$'` | ✅ `modules/pier/token_events_test.go`, `modules/towel/client_ip_test.go`, `modules/cabana/token_resolver_remote_test.go` | ✅ green |
| C-CR-02/D-05 | `auth_time` at mint (both audiences), unchanged through every refresh path, absent for legacy tokens; `VerifyAuthTime` refusals | unit | `go test -short ./modules/bouncer -run '^TestAuthTime$'` | ✅ `modules/bouncer/auth_time_test.go` | ✅ green |
| E-2 (framework) | `ValidTokenScopeName` table; `HasFrontendTokenScopes` discovered; cabana's scope validation agrees with pact | unit | `go test -short ./modules/pact -run '^TestFrontendTokenScopes$'`; `go test -short ./modules/cabana -run '^TestTokenScopeNameParity$'` | ✅ `modules/pact/frontend_token_scopes_test.go`, `modules/cabana/token_scope_name_test.go` | ✅ green |
| B-WR-03 | `javascript:`, `data:`, `vbscript:` and `//` action URLs dropped and logged without the URL; https and same-origin kept | unit | `go test -short ./modules/cabana -run '^TestAccountContributionURL$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| B-WR-06/B-IN-03 | a failing Status marks only its section, a failing Applies drops only its action, 200; dynamic status text not looked up | unit | `go test -short ./modules/cabana -run '^TestAccountContributionsIsolation$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| B-IN-02 | a non-string prompt payload answers the localized `action_prompt_invalid` text (en, pl) | unit | `go test -short ./modules/cabana -run '^TestRecordActionPromptLocalized$'` | ✅ `modules/cabana/record_action_prompt_localized_test.go` | ✅ green |
| B-WR-04 | revoking one `g15_` token fires one revoked event with its TokenID | Postgres | `go test ./modules/cabana -run '^TestAccountTokenRevokeEvent$'` | ✅ `modules/cabana/account_token_events_test.go` | ✅ green |
| B-WR-01/B-WR-02 | plain keys ignored from an open shadow root and a custom field element in a form; Ctrl+S and Ctrl+Enter save the taglist draft | vitest | `npm --prefix admin run test` (`tests/app/hotkeysEditing.test.ts` required by the gate) | ✅ `admin/tests/app/hotkeysEditing.test.ts` | ✅ green |
| B-WR-03/B-WR-06/B-IN-01 | no link and no `window.open` for a `javascript:` URL; section error text; toast and kept sections on a failed reload; My account confirm and stale texts | vitest | `npm --prefix admin run test` | ✅ `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| E-2 | E-2's acceptance list item by item (mint and `middleware.Scope`, the 403 message, 422, listing, discovery in en and pl), per-application catalog, legacy fallback | Postgres | user plugin `GOWORK=off go test . -run '^TestMemberTokenScopes$'` | ✅ `plugins/golem15/user/member_token_scopes_test.go` | ✅ green |
| E-2 | duplicate (both plugins named), redeclared built-ins, bad names, empty label and a missing phrase key fail boot | unit | user plugin `GOWORK=off go test -short . -run '^TestMemberTokenScopesBoot$'` | ✅ `plugins/golem15/user/member_token_scopes_test.go` | ✅ green |
| C-CR-02 | the link proof against login, request, refresh and re-login orders, no `auth_time`, another user's session token | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLinkAuthTime$'` | ✅ `plugins/golem15/user/backend_link_auth_time_test.go` | ✅ green |
| C-WR-01/C-IN-06 | POST show answers like GET with no-store; fragment URL; `backend_links.page_path` | Postgres | user plugin `GOWORK=off go test . -run '^TestBackendLinkShowPost$'` | ✅ `plugins/golem15/user/backend_link_show_test.go` | ✅ green |
| C-CR-01 | `AccessRevokedEvent` from ban, reset, change and admin-set password (ids, Before, reason, transaction, subject event after commit, rollback); banned user loses the link | Postgres | user plugin `GOWORK=off go test . -run '^TestAccessRevocation$'` | ✅ `plugins/golem15/user/access_revocation_test.go` | ✅ green |
| C-IN-04 | MCP personal-token resolve stamps `last_used_ip` | Postgres | user plugin `GOWORK=off go test . -run '^TestUserMCPGuardClientIP$'` | ✅ `plugins/golem15/user/mcp_guard_client_ip_test.go` | ✅ green |
| C-IN-02 | `DELETE /tokens/{id}` fires one revoked event with that TokenID | Postgres | user plugin `GOWORK=off go test . -run '^TestAPITokenRevokeEvent$'` | ✅ `plugins/golem15/user/api_token_events_test.go` | ✅ green |
| E-1 | logo fileupload after the avatar; upload stored with field `logo`; `OrganisationLogoFields` with and without a logo; delete removes both attachments | Postgres | user plugin `GOWORK=off go test . -run '^TestOrganisationLogo$'` | ✅ `plugins/golem15/user/organisation_logo_test.go` | ✅ green |
| C-CR-01 | the verifier and refresh refuse pre-cutoff (same-second), banned and deleted users without a revoke listener; untruncated `Before` | Postgres | oauthserver plugin `go test . -run '^TestMCPVerifierRevocation$'` | ✅ `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| C-CR-01/C-WR-02/C-IN-02 | MCP and OIDC grants before `Before` revoked, later kept; consents and unused codes deleted; link changes revoke only backend tokens with client events after commit; authorized-apps revoke event | Postgres | oauthserver plugin `go test . -run '^TestMCPGrantRevocation$'` | ✅ `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| SC-4/C-CR-01/C-WR-02/C-IN-01/C-IN-02 | demo revocation matrix through the SDK client: ban via the bulk route, unlink and a new consent, read-only prompts, one `usr_` token revoked | e2e | `cd ../sm-demo-app && go test -tags summercms_mcp . -run '^TestMCPRevocationEndToEnd$'` | ✅ `sm-demo-app/mcp_revocation_e2e_test.go` | ✅ green |
| C-WR-01 | `readLinkCode` and `isLinkCode` | vitest | `pnpm --dir ../sm-demo-app/vue-demo-app run test` (`app/lib/linkCode.test.ts` required by the gate) | ✅ `sm-demo-app/vue-demo-app/app/lib/linkCode.test.ts` | ✅ green |
| T-19.7-64 (P-40) | workspace check, consumers built against the unreleased user plugin, unreleased-pin policy and `PHASE197_RELEASED` strict mode | gate | `bash scripts/check-phase19.7.sh --self-test`, `--plugins`, `--consumers`; `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers` exits 1 before the release | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| A-IN-02 | cooler's `KeyPrefix` and `keyPrefix` carry their own doc comments | docs | `bash scripts/check-phase19.7.sh --docs` | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| RA-WR-01 | fan-out never waits on a session lock under the router lock; re-filter guard checks bounded by `mcp.call_timeout` | integration | `go test -short -race ./modules/pier -run '^TestLockScopeUnderWidening$'` | ✅ `modules/pier/lock_scope_test.go` | ✅ green |
| RA-WR-02 | the stateless fingerprint ignores the OAuth token id; a narrow token does not flip a shared server's tool set mid-call | unit + integration | `go test -short ./modules/pier -run '^(TestFingerprintIgnoresTokenID\|TestStatelessSharedBinding)$'` | ✅ `modules/pier/fingerprint_test.go`, `modules/pier/stateless_fanout_test.go` | ✅ green |
| RA-WR-03 | a guard error while widening or recomputing (stateless, stateful, on an event) does not narrow the session | integration | `go test -short ./modules/pier -run '^TestWideningGuardError$'` | ✅ `modules/pier/widening_test.go` | ✅ green |
| RA-WR-04 | LRU eviction spares a server with a listen stream; a revocation still ends the stream | integration | `go test -short ./modules/pier -run '^TestStatelessFanOut$'` | ✅ `modules/pier/stateless_fanout_test.go` | ✅ green |
| RA-WR-05 | prompt Render errors stay out of the client's answer (stateful and stateless) | integration | `go test -short ./modules/pier -run '^TestPromptRenderError$'` | ✅ `modules/pier/prompt_ceiling_test.go` | ✅ green |
| RA-IN-02 | stream bearer re-checks carry the client address | integration | `go test -short ./modules/pier -run '^TestStreamRecheckClientIP$'` | ✅ `modules/pier/token_events_test.go` | ✅ green |
| RA-IN-04 | the non-reserving fallback writes the record before building the server; a failed initialize deletes it (Count-then-Put race deferred) | integration | `go test -short ./modules/pier -run '^TestSessionReserve$'` | ✅ `modules/pier/reserve_test.go` | ✅ green |
| RA-IN-05 | an admin personal token whose scopes all resolve to nothing gets "the token's scopes no longer grant this route", not "account unknown"; the listing check is a refusal; known scope, missing admin and OAuth subject unchanged | Postgres | `go test ./modules/pier -run '^TestBackendScopeUnavailable$'` | ✅ `modules/pier/backend_scope_unavailable_test.go` | ✅ green |
| RA-WR-06/RA-IN-06 | the verdict is qualified while demo pins are unreleased; a silent failing pin check is refused | gate | `bash scripts/check-phase19.7.sh --self-test` (`pins-test-only-unrecorded`, `pins-silent-failure`) | ✅ `scripts/check-phase19.7.sh` | ✅ green |
| RB-WR-01/RB-IN-01 | Ctrl+S and Ctrl+Enter commit a draft inside an open or closed field-type shadow root; control-character and bare `https://` URLs render no link and open no window | vitest | `npm --prefix admin run test` | ✅ `admin/tests/app/hotkeysEditing.test.ts`, `admin/tests/account/AccountContributions.test.ts` | ✅ green |
| RB-WR-02 | a padded or whitespace-carrying token scope name fails boot | unit | `go test -short ./modules/cabana -run '^TestTokenScopeNameParity$'` | ✅ `modules/cabana/token_scope_name_test.go` | ✅ green |
| RB-IN-02 | a failing Applies marks its My account section | unit | `go test -short ./modules/cabana -run '^TestAccountContributionsAppliesFailureFlagsSection$'` | ✅ `modules/cabana/account_contributions_hardening_test.go` | ✅ green |
| RC-WR-01 | a backend grant acts only as the administrator it was consented for: decide 409 matrix, binding through code, exchange, rotation and verifier, link change, revoke racing a rotation | Postgres + integration + e2e | oauthserver plugin `go test . -run '^TestMCPBackendBinding$'`; `go test -short ./modules/pier -run '^(TestToolsList\|TestFanOut)$'`; demo `TestMCPEndToEnd` | ✅ `sm-oauthserver-plugin`, `modules/pier/tools_list_test.go`, `modules/pier/fanout_test.go`, `sm-demo-app/mcp_e2e_test.go` | ✅ green |
| RC-CR-01/RC-WR-02 | a self-service password change revokes grants rotated after the session's iat and keeps the session; a ban alone refuses the verifier and rotation | Postgres | user plugin `GOWORK=off go test . -run '^TestAccessRevocation$'`; oauthserver plugin `go test . -run '^(TestMCPGrantRevocation\|TestMCPVerifierRevocation)$'` | ✅ `plugins/golem15/user/access_revocation_test.go`, `sm-oauthserver-plugin/mcp_revocation_test.go` | ✅ green |
| RC-IN-01/RC-IN-03 | a link code in the query string is ignored on POST; avatar and logo fallbacks use the public path | Postgres | user plugin `GOWORK=off go test . -run '^(TestBackendLinkShowPost\|TestAvatarURL\|TestOrganisationLogo)$'` | ✅ `plugins/golem15/user/backend_link_show_test.go`, `plugins/golem15/user/organisation_logo_test.go` | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Status legend: ✅ green · ❌ red · ⚠️ flaky. Every row is green as of the plan 09 gate run (`bash scripts/check-phase19.7.sh --all`, every stage PASS). The rows after D-31 are the gap closure (plans 06-09); the RA-/RB-/RC- rows are the re-review fixes (19.7-REREVIEW-FIX.md), added by the 2026-10-24 validation audit.*
---
## Wave 0 Requirements
- [ ] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
- [ ] pier test helpers: fake guard provider, fake verifier, two-instance harness on one miniredis, SDK client with a bearer-injecting `http.Client`
- [ ] user-plugin Postgres fixtures for links and frontend permission rows
- [ ] additive-API checks for sm-user-plugin, sm-journal-plugin, sm-oauthserver-plugin
- [x] `scripts/check-phase19.7.sh` — phase gate (pattern: `scripts/check-phase19.4.sh`)
- [x] pier test helpers: fake guard provider, fake verifier, two-instance harness on one miniredis, SDK client with a bearer-injecting `http.Client` (`modules/pier/helpers_test.go`)
- [x] user-plugin Postgres fixtures for links and frontend permission rows (the admin harness, `startLink`/`confirmLink`, the seeded permission rows)
- [x] additive-API checks for sm-user-plugin, sm-journal-plugin, sm-oauthserver-plugin (gate stage `plugins`)
---
@@ -83,11 +134,23 @@ Filled in by the planner per task; the behaviour → test map is:
## 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
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 120s for the quick run (the full gate with Docker takes several minutes)
- [x] Nyquist compliance recorded in the frontmatter
**Approval:** pending
**Approval:** plan 19.7-05 (the gate passes in full); extended by plan 19.7-09 for the gap closure (the gate passes in full)
---
## Validation Audit 2026-10-24
The audit compared the map with the re-review fixes (19.7-REREVIEW-FIX.md). Fifteen behaviours had tests but no row, and they are now in the map. RA-IN-01, RA-IN-03, RB-IN-04 and RC-IN-02 changed only documentation, so they get no row. RA-IN-05 had no test, and `TestBackendScopeUnavailable` (49a3df6) now covers it.
| Metric | Count |
|--------|-------|
| Gaps found | 1 |
| Resolved | 1 |
| Escalated | 0 |

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
# API Coverage — MCP server surface (pier, go-sdk v1.8.0)
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
Phase 19.7 calls no third-party service at runtime. It implements an MCP server (`modules/pier`) on the official `github.com/modelcontextprotocol/go-sdk` v1.8.0 (D-24, `.planning/notes/2026-10-22-mcp-go-sdk.md`), compiled in only with the `summercms_mcp` build tag (D-33) and off by default (D-30). Authorization rides on the oauthserver plugin's MCP flavour from Phase 19.4. The matrix records which parts of the MCP protocol pier serves (INTEGRATE) and which it leaves out on purpose (OPT-OUT), so a client's expectations can be checked against it.
| capability | decision | reason |
|---|---|---|
| transport: streamable HTTP, stateful (`Mcp-Session-Id`, SSE responses) | INTEGRATE | D-25; sessions in Redis (or memory) with a sliding idle TTL (D-27) |
| transport: stateless streamable HTTP for 2026-07-28 clients | INTEGRATE | D-32; go-sdk v1.8.0 serves 2026-07-28 only statelessly |
| transport: version negotiation 2025-03-26, 2025-11-25, 2026-07-28 | INTEGRATE | `negotiatedVersion` mirrors the SDK rule; the header picks the path |
| transport: stdio | OPT-OUT | pier is a network service inside the application binary; there is no local process to spawn |
| transport: legacy HTTP+SSE (2024-11-05) | OPT-OUT | deprecated by the protocol; every supported client speaks streamable HTTP |
| transport: stream resumability (`Last-Event-ID`, event store) | OPT-OUT | tool calls are short HTTP route dispatches; a dropped stream is retried by the client |
| authorization: protected resource metadata (RFC 9728) | INTEGRATE | `prm.go`, through the SDK's `auth.ProtectedResourceMetadataHandler` |
| authorization: 401 with `WWW-Authenticate` (resource metadata, scope) | INTEGRATE | `pier.go`; scope hint `read` added when absent |
| authorization: bearer token verification (MCP grants, member `usr_` tokens) | INTEGRATE | D-20 to D-23; revocation ends or narrows sessions (D-27) |
| authorization: `read`/`write` ceiling and separate `backend` scope | INTEGRATE | D-20, D-21; permissions always filter on top |
| lifecycle: initialize, initialized, ping | INTEGRATE | SDK; `InitializedHandler` arms the session |
| server info: name and version | INTEGRATE | application name and the framework module version |
| tools: `tools/list`, `tools/call` | INTEGRATE | plugin-declared tools dispatched to their HTTP routes (D-12, D-23) |
| tools: annotations (`readOnlyHint` and the rest) | INTEGRATE | `annotationsFor`; `readOnlyHint` drives the `read` ceiling |
| tools: `outputSchema` and `structuredContent` | INTEGRATE | `catalog.go`, `dispatch.go` |
| tools: `notifications/tools/list_changed` | INTEGRATE | D-26; pushed over Redis pub/sub to the instance holding the session |
| prompts: `prompts/list`, `prompts/get` with arguments | INTEGRATE | D-28; filtered and prefixed like tools |
| prompts: `notifications/prompts/list_changed` | INTEGRATE | D-26 |
| resources: `resources/list`, `resources/read`, templates | OPT-OUT | D-28: the capability is shaped for them, a follow-up phase ships them (CONTEXT deferred ideas) |
| resources: `resources/subscribe`, `notifications/resources/updated` | OPT-OUT | follows resources (deferred) |
| completion: `completion/complete` | OPT-OUT | no resources and no enumerable prompt arguments yet; returns with resources |
| logging: `logging/setLevel`, `notifications/message` | OPT-OUT | the capability is not advertised; calls are audited server-side instead (`audit.go`) |
| progress notifications | OPT-OUT | route dispatches are bounded by `mcp.call_timeout`; no long-running tool exists |
| cancellation (`notifications/cancelled`) | INTEGRATE | handled by the SDK; the call context is cancelled and the dispatched request with it |
| client features: sampling (`sampling/createMessage`) | OPT-OUT | tools are plugin HTTP routes; the server never asks the client's model |
| client features: elicitation | OPT-OUT | consent and account linking happen in the browser (oauthserver consent, `/link-admin`), not in the agent |
| client features: roots | OPT-OUT | no filesystem semantics on a CMS server |
| tasks (2025-11-25 experimental) | OPT-OUT | experimental in the protocol; no long-running tool exists |
| pagination of list results | INTEGRATE | handled by the SDK |

View File

@@ -44,3 +44,308 @@ was changed by the plan that recorded it.
instance serves them. Sticky sessions or a per-request re-read of the
stored `initialized` flag would close the gap; not needed for the
single-instance demo.
- cabana's backend subject events never reach pier (found during 19.7-03 Task 2)
status: resolved
**What:** `modules/cabana/subject_events.go` fires `pact.MCPSubjectChanged`
as a value (`app.Events.Fire(ctx, pact.MCPSubjectChanged{...})`), while
`modules/pier/events.go` listens for a pointer
(`func(ctx, ev *pact.MCPSubjectChanged)`). festival.Bus keys listeners by
the type parameter, so pier never sees the backend subject events cabana fires
(admin deletion, permission and role changes, token revocation): no
list_changed and no session close for the `backend` guard from cabana.
cabana also fires inside the save's transaction instead of after commit,
so a rolled-back save would still notify. sm-user-plugin v0.1.10 fires
`*pact.MCPSubjectChanged` (pointer) through `lagoon.AfterCommit`, which
pier does receive. Fix in the framework: fire `&pact.MCPSubjectChanged{}`
from cabana and defer it with `lagoon.AfterCommit`, with a pier test that
a cabana save reaches `handleEvent`. Must be fixed before v0.5.0.
**Resolved:** cabana now fires `&pact.MCPSubjectChanged{}` through
`lagoon.AfterCommit` (immediately outside a transaction);
`subject_events_test.go` pins delivery to a pointer listener, no firing
before commit and none after a rollback.
- pier publishes `*pier.Pier` once per application (found during 19.7-04 Task 1)
status: open
**What:** `pier.Mount` publishes the mounted endpoint on the app, so a
second `surf.BuildRouter`/`surf.Assemble` on the same `*backpack.App`
with `mcp.enabled: true` fails with `backpack: duplicate provider for
*pier.Pier`. A binary builds its router once, so serving is unaffected;
tests that build twice on one app (sm-demo-app `TestRealtimeRoutes`,
`socialServer`) now set `mcp.enabled: false`. A find-or-replace publish
(or skipping the publish when the same app already carries one) in pier
would lift it.
- MCP grants are not in the authorized-apps list (found during 19.7-04 Task 2)
status: open
**What:** the MCP flow (wristband `IssueCode`, sm-oauthserver-plugin's
`MCPStore`) stamps the client's `consented_at` but writes no row to
`golem15_oauthserver_consents`, so `GET /oauth/authorizations` does not
list an MCP client and `DELETE /oauth/authorizations/{client_id}` answers
404 for it. The access token itself can still be revoked (row
`revoked_at`, refused by the verifier at once) and the user cascade
removes every grant, but a user has no self-service "disconnect this
agent" for MCP yet. A consent row per MCP grant (written at `IssueCode`)
would make the existing list and revocation (which already fires the
`revoked` event) cover MCP clients; plan 05's revoke matrix should decide.
**Plan 05 decision:** accepted for this phase (19.7-SECURITY.md AR-19.7-03):
access still ends through client revocation, user deletion or
deactivation, token expiry and the verifier's refusal of a revoked token,
and `TestMCPEndToEnd` pins that the `revoked` event ends the session. The
consent row per MCP grant stays the follow-up.
- Pre-existing `gofmt` drift in sm-journal-plugin (found during 19.7-04 Task 3)
status: open
**What:** `gofmt -l` lists `classes/blog_editor_test.go` before this plan
touched the repository. Left as is.
- sm-demo-app push and the demo deploy wait for the user (found during 19.7-04 Task 5)
status: open
**What:** sm-demo-app master is eight commits ahead of origin (a25444e,
985af53 and this plan's 44cac13, 8668918, the pins aec16c4 oauthserver
v0.1.2, 996586c journal v0.2.3, 7e76957 vue-demo-app 21a22b6, and
c21ae9d with the nginx MCP locations and DEPLOY.md "MCP endpoint"). None
is pushed and nothing is deployed: both need the user's go-ahead. The
deploy also installs the changed vhost (DEPLOY.md, "When
deploy/nginx/demo.summercms.io.conf changed") and runs the MCP smoke.
The sm-oauthserver-plugin v0.1.2 and sm-journal-plugin v0.2.3 tags and
the vue-demo-app commit are pushed.
- Release builds pinning summercms v0.4.4 lack the cabana subject-event fix (found during 19.7-04 Task 5)
status: open
**What:** the demo (GOWORK=off release build), oauthserver v0.1.2 and
Journal v0.2.3 require summercms v0.4.4, which predates d96c726
(cabana fires `*pact.MCPSubjectChanged` after commit). Development
builds use the go.work replace and carry the fix; a release binary does
not: admin deletion, permission or role changes and token revocation in
the admin do not end MCP sessions or send list_changed until the next
framework tag is required by the demo. The verifier and the backend
resolver still refuse at the next request, so access does not outlive
the change; only the live session notification is missing. Deploy the
demo after the next framework tag, or accept the gap for the demo.
- Redis for MCP sessions on rome (found during 19.7-04 Task 5)
status: open
**What:** the demo runs one instance with `mcp.sessions.store: memory`.
A second instance needs `SUMMER_MCP__SESSIONS__STORE=redis` and
`SUMMER_REDIS__URL` on every instance (DEPLOY.md, "MCP endpoint") and a
Redis on rome; also see the `notifications/initialized` item above.
- Real MCP client check of the demo not run (found during 19.7-04 Task 5)
status: open
**What:** Task 5's human check (a real client against the local demo:
OAuth sign-in on /connect, user_me only, link an admin through
/link-admin, reconnect with backend, the journal_ tools, a draft post,
unlink) was not performed in the executor run; the automated
TestMCPEndToEnd covers the same path. The local setup is DEPLOY.md,
"MCP endpoint", "Locally".
- Retracted `github.com/gabriel-vasile/mimetype` v1.4.15 in sm-demo-app (found during 19.7-04 Task 5)
status: open
**What:** `go get` in sm-demo-app warns that mimetype v1.4.15 is
retracted (quadratic time on crafted CDF files). It arrives indirectly
and predates this plan; a `go get github.com/gabriel-vasile/mimetype@latest`
in the framework (and a tag) would lift it for every consumer.
- Framework fixes of plan 05 are on master only (found during 19.7-05 Tasks 1-2)
status: open
**What:** c49983d `fix(cabana)` (the `backend` middleware refuses a
dispatch marker with a frontend principal), c29d228 `fix(pier)` (a
`tools/call` without arguments) and 1111828 `fix(pier)` (an ended session
is not written back by a late request) are not in any tag. Applications
pinning summercms v0.4.4 (the demo's release build, oauthserver v0.1.2,
Journal v0.2.3) lack them until they require the next framework tag
(v0.5.0 at phase completion, after the user's go-ahead).
**Gap closure (19.7-09):** the same holds for every framework fix of plans
06 and 07; they ship together in v0.5.0 (see "Release order after the gap
closure" below).
- Plan 05 test commits are local and untagged (found during 19.7-05 Task 3)
status: resolved
**What:** sm-user-plugin 72de822 (in the sm-demo-app submodule checkout
`plugins/golem15/user`), sm-oauthserver-plugin 2929c91, sm-journal-plugin
a9a9b80 and sm-demo-app 6fa2d56 add tests only; none is pushed or tagged.
sm-demo-app's `plugins/golem15/user` working tree is ahead of its v0.1.10
pin by that test-only commit and the pointer is not committed;
`scripts/check-pins.sh` reports the mismatch, which the phase gate accepts
only because the commits touch `*_test.go` files alone. A patch tag
(sm-user-plugin v0.1.11, sm-oauthserver-plugin v0.1.3, sm-journal-plugin
v0.2.4) is optional; it would change no shipped code. Push and tags wait
for the user.
**Superseded (19.7-09):** the plugins now also carry the non-test fixes of
plan 08 and the tests of plan 09, so the plan 05 test commits ship in the
gap-closure plugin tags that require framework v0.5.0; the entries "Demo
plugin submodules and pointers wait for the release" and "Release order
after the gap closure" below track them.
- Superuser refusal switch, conditional (found during 19.7-05 Task 4)
status: open
**What:** the security review accepted superuser reach through an agent
(19.7-SECURITY.md AR-19.7-02) and did not add
`mcp.refuse_superuser_backend`. When a plugin declares a superuser-only or
destructive backend tool beyond content editing, add the switch (a
`fix(pier)` with README and docs) and cut a framework patch tag with the
user's go-ahead.
- B-IN-05: a prompted record action refused with 422 closes the dialog (found in the code review, deferred by 19.7-07)
status: open
**What:** when a prompted record action answers 422, the SPA shows the
error toast and closes the prompt dialog, so the typed text is lost.
Plan 07 corrected the contract text (the 422 toast behaviour is now what
the docs and the OpenAPI description say). Keeping the dialog open needs a
change to the shared confirm primitive every action uses (the dialog
resolves before the request answers), which is out of the gap closure's
scope. Follow-up: let the confirm primitive keep the dialog open on a
refused submit and show the field error inline.
- B-IN-06: `window.open` after an awaited POST can be blocked by a popup blocker (found in the code review)
status: resolved
**What:** no fix required. My account always renders the answered URL as
a visible link next to the section (`data-account-contribution-url`), so
an administrator whose browser blocked the popup clicks the link
(pinned by `admin/tests/account/AccountContributions.test.ts`).
- `admin:token:revoke` fires no MCP event (found during 19.7-07)
status: open
**What:** the CLI command revokes an admin personal token's row but does
not fire the token-specific `revoked` subject event that the My account
revoke fires (`fireTokenRevoked`). The verifier side refuses the revoked
token on the next request, so access ends; only a live MCP session of
that token keeps its stream until its next request or re-check. Same
shape as before plan 07. Fix: call the same helper from the command after
its transaction.
**Re-review (RB-IN-03):** kept deferred. Calling `fireTokenRevoked` from
the command is not enough: the command runs in its own process, which
mounts no MCP endpoint, so no pier listener receives the event and nothing
reaches the store a running server reads. Closing it needs the command to
publish into pier's store directly (a cross-module seam). 4d7eb9d
documents in the cabana README that only the My account revoke fires the
event.
- RB-IN-04 optional narrowing: a focused widget host also mutes Esc, / and ? (found in the gap-closure re-review)
status: open
**What:** 89b85f5 documents the trade-off in `admin/src/app/hotkeys.ts`.
The optional narrowing to plugin field types (a `data-field-type` marker)
was not made: a `type: widget` element with an input in a closed shadow
root would then let Esc leave the form while the admin types in it.
- OIDC grants on a ban or a password reset (decided during 19.7-08)
status: resolved
**What:** the user decided on 2026-10-23 that a ban, a password reset, an
administrator setting a password and the user's own password change end
both the MCP and the OIDC grants of the user (tokens revoked, consents
and unused codes deleted, subjects kept). Implemented by
`Cascade.OnAccessRevoked` (sm-oauthserver-plugin 671513d) and pinned by
`TestMCPGrantRevocation` ("access revoked ends earlier MCP and OIDC
grants and keeps later ones") and `TestMCPRevocationSmoke`.
- Local sm-oauthserver-plugin go.work retarget (found during 19.7-08 Task 2)
status: open
**What:** the tracked `go.work` of sm-oauthserver-plugin is modified in
the working tree only: its `sm-user-plugin` replace points at
`../sm-demo-app/plugins/golem15/user` (the canonical user checkout of the
gap closure) instead of the committed `../fonoteka.go/plugins/golem15/user`.
The phase gate's workspace check requires exactly that target, so the
gate passes on this machine only with the local edit. Before the release
either restore the committed line or decide whether go.work should leave
version control; never commit the local retarget.
- Demo plugin submodules and pointers wait for the release (found during 19.7-08, updated by 19.7-09)
status: open
**What:** in sm-demo-app the working-tree checkouts of
`plugins/golem15/oauthserver` (671513d, its canonical checkout is at
65f71dc with plan 09's test), `plugins/golem15/journal` (b129902,
canonical at 2aeeccc) and `plugins/golem15/user` (the canonical user
checkout itself, 38551a4) are ahead of their committed pins (v0.1.2,
v0.2.3, v0.1.10) by unreleased commits. The gate reports them as
`pins: … is ahead of its pin by unreleased commits` and refuses them with
`PHASE197_RELEASED=1`. The pointers are committed only after the plugin
tags. sm-demo-app's `vue-demo-app` pointer is committed (00f57bf, then
f53d30a for the link code test): vue-demo-app (0125e29, b973a76) must be
pushed before sm-demo-app.
- Release order after the gap closure (decided 2026-10-23)
status: open
**What:** nothing of plans 06-09 is tagged or pushed. After the re-review
of the gap closure, phase verification, Nyquist validation and UAT pass,
and on the user's go-ahead:
1. framework v0.5.0 (and the changelog post);
2. the sm-user-plugin, sm-oauthserver-plugin and sm-journal-plugin tags
that require it;
3. the bumps in sm-demo-app, fonoteka.go and sm-bm-app, with vue-demo-app
pushed before sm-demo-app;
4. sm-servers-app picks up E-1 and E-2 after the user plugin tag;
5. then `PHASE197_RELEASED=1 bash scripts/check-phase19.7.sh --consumers`
must pass (every demo pin on its tag, no drift).
- `readLinkCode` accepts a valid code next to other fragment parameters (found during 19.7-09 Task 3)
status: resolved (gap-closure re-review: not a defect; vue-demo-app 9c247ab pins the lenient parse)
**What:** plan 09 described "extra parameters give ''" for the link page's
code reader. `readLinkCode` parses the fragment with URLSearchParams, so
`#code=<64 hex>&next=/x` answers the code, while an extended or foreign
value (`#code=<hex>x`, `#other=<hex>`) answers ''. `linkCode.test.ts`
pins the latter and leaves the former unasserted. My account only ever
sends `#code=<code>`, so nothing depends on it; the re-review decides
whether to keep the lenient parse or require the fragment to be exactly
`code=<64 hex>`.
- `docs/examples/blog` test failed once on GOPROXY=off (found during 19.7-09 Task 1)
status: open
**What:** one `go test -short ./...` run of the gate's go stage failed in
`docs/examples/blog` with "module lookup disabled by GOPROXY=off" for
`golang.org/x/exp`; the package passed alone and in more than twenty later
full runs. The test builds a scratch module offline and depends on the
module cache holding every dependency; a run that coincides with a cache
change may miss one. Not caused by this phase's code; watch for a
recurrence.
- RA-IN-04: the cap of the non-reserving store fallback is still check-then-act (found in the gap-closure re-review, deferred by its lane A fixes)
status: open
**What:** 034aa19 makes `serveNew`'s fallback for a store without
`Reserve` write the record before the server is built, so a reaper pass
can no longer close a session that has no record yet. The Count-then-Put
cap of that fallback remains racy. Closing it means making `Reserve` part
of the exported `pier.Store` interface or refusing a store without it at
mount, which changes pier's exported API for a branch only a test wrapper
(`WrapStore`) reaches; both shipped stores reserve atomically.
- RA-WR-06 optional: the demo pin check does not require unreleased commits to be on the canonical checkout's upstream (found in the gap-closure re-review)
status: open
**What:** 2fdda99 qualifies the gate's verdict ("Phase 19.7 gate passed
WITH UNRELEASED PINS: the release pins are not yet proven") while demo
submodules carry unreleased non-test commits; the exit status stays 0 so
the pre-release verification can run. The review's optional
`git branch -r --contains` check was not added: the gap-closure commits
are deliberately unpushed until the release order runs, so it would
refuse every pre-release run. `PHASE197_RELEASED=1` after the tags is
the release proof.
- RC-CR-01 optional: base the session cutoff on `auth_time` (found in the gap-closure re-review, lane C)
status: open
**What:** the self-service password change now revokes every OAuth grant
as of the request (sm-user-plugin 30f7a01). The calling session's own
cutoff is still its `iat` second, so an attacker's frontend session JWT
refreshed after the victim's `iat` survives the change too. That predates
this phase; closing it means basing `tokens_valid_after` on the calling
token's `auth_time` and having `POST /refresh` and the jwt guard compare
`auth_time` with the cutoff, a change to session revocation in a core
plugin (and to how bouncer's refresh is checked) that needs its own
decision.
- RC-IN-01 nginx: keep the deprecated link GET route's code out of access logs (found in the gap-closure re-review, lane C)
status: open
**What:** sm-user-plugin 92676ba makes the show and confirm POSTs ignore
a code in the query string. `GET /_user/api/v1/backend-link/{code}` stays
for compatibility (removed only in a major release), so nginx still logs
the code if a third-party client calls it. `access_log off;` (or a
`log_format` without `$request_uri`) for
`location ~ ^/_user/api/v1/backend-link/` in the demo's nginx config is a
deploy change, made with the next demo deploy; the shipped page never
calls the GET route.
- RC-IN-06: the legacy MintAPIToken fallback is widened before the phrase-key check (found in the gap-closure re-review, lane C)
status: open
**What:** `classes.CompileAPITokenScopes` adds the declared names to the
process-wide fallback set before `bootAPITokenScopes` checks their phrase
keys, so a boot that fails on a missing key has already widened what
`MintAPIToken` accepts. Moving the add after the check needs a new
exported seam or a behaviour change of the exported
`CompileAPITokenScopes` in a core plugin; a failed boot ends the process,
so the widened set is never used in production. `MintAPIToken` is now
marked `Deprecated:` in favour of `MintAPITokenFor` (sm-user-plugin
9892404), which checks the application's own catalog.
- RC-IN-07: sm-oauthserver-plugin builds only through its workspace file (found in the gap-closure re-review, lane C)
status: open (release-time)
**What:** the plugin uses `userclasses.AccessRevokedEvent`,
`FireSubjectEvent` and `FireSubjectEventAfterCommit`, which the required
sm-user-plugin v0.1.10 lacks, so `GOWORK=off go build ./...` fails. The
release checklist for the oauthserver tag must bump `sm-user-plugin` to
the new user plugin tag and pass `GOWORK=off go build ./...` before the
tag; the requires are not bumped before the user plugin is tagged.
- Lane C re-review fixes moved the demo's oauthserver checkout (gap-closure re-review, lane C)
status: open
**What:** pier now gives a backend subject only to a grant that records
its consented administrator (`pact.MCPGrant.BackendUserID`, framework
c37c6ce), so the demo's e2e tests need the oauthserver commits that
record it. The working-tree checkout of sm-demo-app's
`plugins/golem15/oauthserver` was moved from 671513d to the canonical
b6a9586 (detached, fast-forward; the pointer stays uncommitted until the
plugin tag, as before). Canonical heads after the lane C fixes:
sm-user-plugin 9892404, sm-oauthserver-plugin 9813c1d,
sm-journal-plugin 521df9a (its prompt test's fake grants now name the
consented administrator), vue-demo-app 9c247ab. `backend` grants issued before the oauthserver
migration `202610230001` record no administrator and stop acting as one
until the agent is connected again.

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 @@

View File

@@ -0,0 +1,376 @@
---
phase: 32-seo-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/form_extensions.go
- modules/cabana/http.go
- modules/cabana/crud.go
- modules/cabana/extension.go
- modules/cabana/actions.go
- modules/cabana/partial_render.go
- modules/cabana/field_type.go
- modules/cabana/field_ml.go
- modules/cabana/form_extensions_smoke_test.go
- modules/cabana/form_extensions_postgres_test.go
- modules/cabana/example_form_extension_test.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/setup/coming-from-wintercms.md
- admin/tests/form/FormView.test.ts
autonomous: false
requirements: [SC-1, SC-5, D-91, D-101, D-90, D-98, D-100, D-104]
estimate:
tokens: 140000
raw_tokens: 140000
tasks: 4
confidence: low
must_haves:
truths:
- "A plugin implementing pact.HasAdminActionExtensions returns an AdminActionExtension whose Form adds field C After A to another plugin's form of A, B, and the admin form schema lists A, C, B (SC-1, D-91)"
- "A field placed Before the first owner field compiles first; a field with no anchor goes to the end of its tab, as in WinterCMS (D-91)"
- "A new extension tab placed After one owner tab and Before the next renders between them; an owner form with no tabs plus an extension tab renders the default tab (Basic) first, then the new tab (D-91, UI-SPEC C1)"
- "Two plugins anchored at the same field keep plugin activation order (party, by Requires()); an anchor may name another extension's field only when that plugin is in the contributor's Requires() (D-91)"
- "An extension hides an owner field through Hide; the field is absent from the schema and from show, and a tab that loses all its fields disappears (D-91)"
- "Unknown field or tab anchor, duplicate field name, duplicate tab key, an anchor cycle, an anchor outside Requires(), Before the default tab, any Zone other than the tab zone, Hide of an unknown or referenced field and a fileupload extension field each stop the boot with an error naming the plugin, the controller and the offending name (SC-1, D-91, D-101, D-100)"
- "Extension Load runs on show and Save runs inside the record's save transaction after the owner row is written; a Save error rolls back the whole save and leaves the owner row unchanged; AdminFormErrors answer 422 field lines and per-field notices render as save notices (D-91)"
- "Extension field values never reach lagoon.Fill, BindWritableFields or the owner's columns, translation rows or attachments (D-91)"
- "An admin without the extension's Permissions, or a request where Visible answers false, gets no extension field in schema or show, and a save ignores posted extension keys and does not call Save (D-91, UI-SPEC C1 Permission)"
- "A form that no extension targets compiles byte-identically to before this plan: its CanonicalJSON is unchanged and owner tab interleaving is not canonicalised (strictly additive)"
- "The framework change is strictly additive: new optional struct fields and new types only; existing pact, cabana and signpost exported signatures and behaviour are unchanged, no method is added to an existing interface, and existing plugins compile and behave the same without edits (D-106)"
- "The SPA renders the compiled order as sent and adds no ordering code: admin/src is unchanged by this plan"
- "Field span (left, right, full) keeps working next to inserted fields (D-91)"
- "One plugin extending two controllers and two plugins extending one controller both compile deterministically (assumption-delta invariant)"
- statement: "SPA test: a schema whose compiled order puts an extension field between two owner fields renders A, C, B, and a tab-less form plus an extension tab renders Basic, then the new tab (UI-SPEC C1 backstop)"
verification: backstop
prohibitions:
- statement: "No breaking change to existing framework API: no exported pact, cabana or signpost identifier is removed or changes signature, and no existing behaviour changes for plugins that do not use the new seam"
status: resolved
verification: test
- statement: "An extension must never write the owner plugin's model columns, translation rows or attachment relations; the owner's model and tables stay untouched (D-91)"
status: resolved
verification: test
- statement: "Framework code, READMEs and docs added by this plan name no SEO concept and no consuming application (D-90)"
status: resolved
verification: test
artifacts:
- path: "modules/pact/capabilities.go"
provides: "AdminActionExtension.Form, AdminFormExtension, AdminFormTab, AdminFormField, AdminFormLoad, AdminFormSave, AdminFormSaveResult, AdminFormErrors, zone constants"
contains: "Form *AdminFormExtension"
- path: "modules/cabana/form_extensions.go"
provides: "compileFormExtensions: placement pass, boot errors, extension-owned field registry"
contains: "func compileFormExtensions("
- path: "modules/cabana/form_extensions_postgres_test.go"
provides: "TestFormExtensionTracer and TestFormExtensionSave (save transaction, rollback, permissions)"
contains: "func TestFormExtensionTracer("
- path: "modules/cabana/example_form_extension_test.go"
provides: "the docs snippet source for the form extension section"
contains: "AdminFormExtension"
- path: "admin/tests/form/FormView.test.ts"
provides: "UI-SPEC C1 backstop cases"
contains: "extension"
key_links:
- from: "modules/cabana/http.go"
to: "modules/cabana/form_extensions.go"
via: "Activate calls compileFormExtensions(reg, plugins) after compileRegistryWithTypes and before compileActionExtensions"
pattern: "compileFormExtensions\\(reg, plugins\\)"
- from: "modules/cabana/crud.go"
to: "modules/pact/capabilities.go"
via: "CRUDService.save lifts extension values before projection and calls AdminFormExtension.Save inside s.transaction"
pattern: "\\.Save\\(ctx, pact\\.AdminFormSave"
- from: "docs/backend/admin-controllers.md"
to: "modules/cabana/example_form_extension_test.go"
via: "src= snippet kept in sync by summer docs:sync"
pattern: "example_form_extension_test.go"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan delivers the first framework seam the goal rests on: a plugin can place fields and tabs inside another plugin's admin form.
<objective>
Implement D-91 cross-plugin admin form extensions in the framework: `pact.AdminActionExtension` gains `Form *pact.AdminFormExtension`; cabana compiles the placement once at boot (`Before`/`After` field and tab anchors, the tab zone only per D-101), dispatches extension-owned widgets, partials, plugin field types and translatable fields to the contributing plugin, keeps extension values out of the owner's model, and runs the extension's `Load` on show and `Save` inside the record's save transaction. README, docs and an SPA backstop test land with it. The framework names no SEO concept (D-90); fileupload stays out of extensions because OG images use a plugin field type instead (D-100); the SEO plugin later dogfoods the seam on its own settings form (D-104).
Purpose: SC-1 ("A, B plus C after A renders A, C, B"; a tab between two tabs; clear boot errors). Every later plan of this phase (D-98 order) builds the SEO tab and the per-type settings tabs on this seam.
Output: the pact types, `modules/cabana/form_extensions.go`, the save/show/schema wiring, a tracer test through the admin HTTP API, docs, and the UI-SPEC C1 backstop test. Which plan implements each decision of the phase, and what Phase 33 takes, is in `32-DECISIONS-MAP.md`.
**Planner decisions recorded here (Claude's discretion: the exact Go shape of the seam, CONTEXT):**
- **P-01 (placement data in Go, definitions in fields.yaml keys):** an extension declares tabs and fields as Go structs (`AdminFormTab`, `AdminFormField` with `Before`, `After`, `Zone`, matching D-91's and D-101's wording) and each field's ordinary fields.yaml keys in `AdminFormField.Config` (a `map[string]any` holding label, type, span, comment, options and the rest). cabana encodes `Config` with goccy/go-yaml and compiles it through the same `decodeFieldMapping`/`compileFieldNode` path as a fields.yaml entry, so every type, option and plugin-field-type check applies unchanged. Go declarations let a plugin generate fields at boot (the per-type settings tabs of D-104) without templating YAML. Confirmed or replaced at the Task 1 checkpoint.
- **P-02 (canonicalisation only when extended):** the owner's field list is regrouped by tab in first-appearance order (the default tab first, as the SPA renders it) only for a controller that at least one extension targets. Every other form compiles byte-identically, so the change is strictly additive. Consequence: Journal Post renders Edit | Manage | Categories | SEO (first appearance), not the order UI-SPEC C3 wrote (RESEARCH Pitfall 2).
- **P-03 (zones, D-101):** `AdminFormField.Zone` accepts "" and `pact.AdminFormZoneTabs`; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` exist as constants and stop the boot with "zone %q is not supported yet".
- **P-04 (tab keys):** a tab's stable name is its raw `tab:` value (the label phrase key), exactly as owner fields.yaml uses it; the reserved name `default` names the tab of fields without `tab`. `Before: "default"` is a boot error; `After: "default"` is allowed.
- **P-05 (anchor cursor):** several extensions anchored at the same point insert after the last field already inserted at that anchor, so plugin order is kept (RESEARCH Pattern 1 step 2).
- **P-06 (errors and notices):** `pact.AdminFormErrors` (map of field name to messages, implements `error`) answers 422 with the existing ValidationError details; `AdminFormSaveResult.Notices` (map of field name to `[]pact.FormFieldNotice`) is merged into the save's `Meta.Notices` through the existing `pluginNotice`/`localizeNotices` path. A notice may name an owner field (the SEO plugin puts its address-change notice on the owner's slug field).
<assumption_delta_decision>
Signal: pluralization of "another" in SC-1 ("a field between two fields of another plugin's admin form").
Primary noun: the target admin controller, named by its controller ID; every compiled field is identified by its stable field name across the owner and all contributors.
Decision: no-change. `pact.AdminActionExtension` is already keyed by `Controller` and `HasAdminActionExtensions` returns a slice, so any number of contributing plugins can target any number of controllers without a new identity model; promotion is not needed because the controller ID already is the general key.
Invariant test: `TestFormExtensionTwoPluginsSameAnchor` and `TestFormExtensionOnePluginTwoControllers` in `modules/cabana/form_extensions_smoke_test.go` assert a deterministic compiled order for both shapes.
</assumption_delta_decision>
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-RESEARCH.md
@.planning/phases/32-seo-foundation/32-PATTERNS.md
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
@.planning/phases/32-seo-foundation/32-VALIDATION.md
@CLAUDE.md
@modules/pact/capabilities.go
@modules/cabana/action_extensions.go
@modules/cabana/http.go
<interfaces>
From modules/pact/capabilities.go (verbatim, lines 429-439):
type AdminActionExtension struct {
Controller string
RecordActions []AdminRecordAction
}
type HasAdminActionExtensions interface {
AdminActionExtensions() []AdminActionExtension
}
The doc comment above it ends "Only record actions are carried for now; the struct leaves room for more kinds."
From modules/pact/capabilities.go (existing types this plan reuses):
type AdminAction struct { ... } // widget actions (line 226)
type FormFieldNotice struct { Message string; Params map[string]string; Count int }
type FormFieldError struct { Messages []string }
type FormFieldType struct { Name, Element string; Translatable bool; Scripts, Styles, Options []string; CheckOptions; Decode; Load; ListValue; Upload }
From modules/cabana/http.go Activate (lines 108-143): compileFieldTypes(plugins) → compileRegistryWithTypes(items, fieldTypes) → checkFileLimits → checkDeferredModels → compileActionExtensions(reg, plugins) → compileAccountContributions → compileContributions.
From modules/cabana/action_extensions.go: func compileActionExtensions(reg *Registry, plugins []party.Plugin) error — iterates plugins in party order, type-asserts pact.HasAdminActionExtensions, looks up reg.byID[ext.Controller], errors "cabana: plugin %s adds record actions to unknown admin controller %q", copies compiled slices before appending. It skips an extension with len(ext.RecordActions) == 0, so an extension carrying only Form passes through it unchanged.
From modules/cabana/crud.go: CRUDService.save (line 736) order: liftVirtualValues → liftMLValues → s.transaction{ loadRecord → decodePluginFields → sanitizeRichEditors → lagoon.Fill → BeforeValidate → lagoon.Validate → formBefore* → tx.Save/Create → applyMLTranslations → formAfter* → hydrateMLRecord → hydratePluginFields } → result.Meta.Notices = localizeNotices(ctx, s.tr, notices). BindWritableFields (line 233) refuses "field X is not a model column".
From modules/cabana/extension.go: compileExtension (line 60) checks widget tag prefix against the owner plugin, fill keys as writable scalars, AdminClientAssets JS; compilePartials (line 200) reads _<name>.htm from the owner's ConfigDir and requires pact.AdminPartialData.
From modules/cabana/http.go (line 857): hiddenSchemaFields(ctx, cc) — schema-only, per request.
From admin/src/components/form/formState.ts: DEFAULT_TAB = 'default'; tabOf(field) = field.tab ? `tab:${field.tab}` : DEFAULT_TAB. FormView.vue builds tabs by first appearance and unshifts the default tab.
</interfaces>
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the Go shape of the D-91 form-extension seam (one-way framework API)</name>
<decision>Which Go shape does `pact.AdminActionExtension.Form` publish for cross-plugin form fields and tabs (D-91, D-101)?</decision>
<context>D-91 is confirmed, but its exact Go shape is Claude's discretion (CONTEXT) and becomes published framework API at the phase's release tag; changing it afterwards breaks every plugin that implements it (RESEARCH A1). The shape recommended here (P-01): `type AdminActionExtension struct { Controller string; RecordActions []AdminRecordAction; Form *AdminFormExtension }`; `type AdminFormExtension struct { Tabs []AdminFormTab; Fields []AdminFormField; Hide []string; Permissions []string; Visible func(ctx context.Context) bool; Dir string; Actions []AdminAction; PartialData func(ctx context.Context, name string, record any) (any, error); Load func(ctx context.Context, in AdminFormLoad) (map[string]any, error); Save func(ctx context.Context, in AdminFormSave) (AdminFormSaveResult, error) }`; `type AdminFormTab struct { Name, Before, After string }`; `type AdminFormField struct { Name, Tab, Before, After, Zone string; Config map[string]any }`; `type AdminFormLoad struct { DB *gorm.DB; Controller string; Record any; ID string }`; `type AdminFormSave struct { DB *gorm.DB; Controller string; Record any; ID string; Created bool; Values map[string]any }` (plan 32-02 adds `Changes []RecordChange`); `type AdminFormSaveResult struct { Notices map[string][]FormFieldNotice }`; `type AdminFormErrors map[string][]string`; constants `AdminFormZoneTabs = "tabs"`, `AdminFormZoneOutside = "outside"`, `AdminFormZoneSecondary = "secondary"`. `Dir` is the directory in the contributing plugin's AdminFS that holds the `_<name>.htm` files of its partial fields. Translatable extension fields carry `map[string]string` (locale to value) in Load and Save.</context>
<options>
<option id="go-placement">
<name>Go-declared placement, fields.yaml keys in Config (recommended, P-01)</name>
<pros>Before, After and Zone are struct fields as D-91 and D-101 word them; a plugin generates fields at boot (per-type settings tabs, D-104) without templating YAML; Config is compiled by the existing fields.yaml checks</pros>
<cons>An extension's field definitions live in Go, not in a fields.yaml file next to the owner's</cons>
</option>
<option id="yaml-document">
<name>A fields.yaml document per extension with before:/after:/zone: keys and a top-level tabs: list</name>
<pros>Same file format as owner forms</pros>
<cons>Generated fields (one tab per content type) need templated YAML bytes; Zone exists only as a YAML key, not as the struct field D-101 names</cons>
</option>
<option id="amend">
<name>The recommended shape with Jakub's renames or additions</name>
<pros>Lets Jakub fix names before they become published API</pros>
<cons>The executor applies the amendments to every task of this plan</cons>
</option>
</options>
<resume-signal>Answer go-placement, yaml-document, or amend with the changes; the executor records the answer in 32-01-SUMMARY.md and uses it in Tasks 2-4</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "plugin B adds field C after A on plugin A's form" through the admin API — one path only</name>
<reversibility rating="one-way">The pact types become published framework API at the release tag; plugins (sm-seo-plugin first) implement them and a later change breaks their builds.</reversibility>
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_extensions.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/form_extensions_postgres_test.go, docs/backend/admin-controllers.md</files>
<read_first>
- modules/pact/capabilities.go (AdminActionExtension 405-439, AdminAction 226-268, FormFieldNotice 584-590, FormFieldError 612-622)
- modules/cabana/action_extensions.go (whole file: the pass to copy)
- modules/cabana/http.go (Activate 90-160)
- modules/cabana/crud.go (ProjectWritableFields 201, BindWritableFields 233, save 736-944, projectFullRecord 689)
- modules/cabana/form_schema.go (decodeFieldMapping 496, compileFieldNode 528, FormSchema.Localize 263, CanonicalJSON 313)
- modules/cabana/field_type_postgres_test.go (newListService harness, lines 100-130) and modules/cabana/action_extensions_postgres_test.go (HTTP-level assertions)
- modules/pact/README.md and docs/backend/admin-controllers.md ("Adding record actions to another controller", line 497)
- The Task 1 answer recorded by the executor
</read_first>
<action>
Apply the shape chosen at Task 1 (the text below assumes go-placement; for yaml-document or amend, keep the same behaviour and change only the declaration form). Per D-91 and D-101:
1. pact: add `Form *AdminFormExtension` as the last field of `AdminActionExtension` and the types `AdminFormExtension`, `AdminFormTab`, `AdminFormField`, `AdminFormLoad`, `AdminFormSave`, `AdminFormSaveResult`, `AdminFormErrors` (with `Error() string` joining field and messages) and the three zone constants. Rewrite the doc comment's last sentence so it says record actions and form contributions are carried, and list the D-91 boot errors in prose the way the existing comment lists the record-action ones. Only additions: no existing field, method or comment contract changes meaning.
2. cabana `form_extensions.go`: `compileFormExtensions(reg *Registry, plugins []party.Plugin) error` iterating plugins in party order like `compileActionExtensions`; skip `ext.Form == nil`; unknown controller → `cabana: plugin %s extends the form of unknown admin controller %q`; a target with no form → error naming both. For the tracer, implement only: compile each `AdminFormField.Config` through goccy/go-yaml encoding plus `decodeFieldMapping` against the boot's field type registry, insert it `After`/`Before` a named owner field of the same tab (unknown anchor → `cabana: plugin %s: controller %q: field %s: unknown anchor %q`), record the field in a per-controller map of extension-owned field name → contributing plugin and extension, and when the controller is extended, regroup the owner list by tab in first-appearance order with the default tab first (P-02). Wire it in `http.go` `Activate` directly after `compileRegistryWithTypes` and before `checkFileLimits` with a comment naming D-91.
3. cabana `crud.go`: before `ProjectWritableFields`, lift the submitted values of extension-owned fields out of the body (they never reach Fill or `BindWritableFields`; make `BindWritableFields` skip extension-owned fields); inside `s.transaction`, after the owner row is written and its translations applied, call each visible extension's `Save` with `pact.AdminFormSave{DB: tx, Controller, Record, ID, Created, Values}`; a returned error aborts the transaction (an `AdminFormErrors` maps to the existing `ValidationError` 422 with those field lines). On show (`ShowRecord`/`projectFullRecord`) call `Load` with the read transaction and merge its map into the record data.
4. Tracer test `TestFormExtensionTracer` in `form_extensions_postgres_test.go`: an owner plugin `acme.blog` with controller `acme.blog.posts` (model with columns a, b; fields a, b as text), a contributor `acme.extra` whose extension adds text field `c` After `a`, storing c in its own table `acme_extra_values` (record id, value) from Save and reading it in Load. Through the admin HTTP routes: the form schema lists a, c, b; PUT with {a, b, c} stores a and b in the owner row and c in `acme_extra_values` in the same transaction; GET shows c; the owner model has no c column.
5. Same commit (documentation rule): `modules/pact/README.md` lists the new types; `docs/backend/admin-controllers.md` gains "Adding fields to another controller's form" after "Adding record actions to another controller" with a minimal prose example using neutral names (acme). Name no SEO concept and no consuming application (D-90).
Commit: `feat(32-01): cross-plugin admin form extensions (tracer)` staging exactly the files above.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestFormExtensionTracer' -count=1 -v 2>&1 | tail -30</automated>
<fails_when>non-zero exit, "FAIL" or "no tests to run" in the output, or no "--- PASS: TestFormExtensionTracer" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Form \*AdminFormExtension" modules/pact/capabilities.go` prints exactly one line
- `grep -n "func compileFormExtensions(reg \*Registry, plugins \[\]party.Plugin) error" modules/cabana/form_extensions.go` prints one line
- `grep -n "compileFormExtensions(reg, plugins)" modules/cabana/http.go` prints one line located before the `compileActionExtensions(reg, plugins)` line
- `go test ./modules/cabana -run TestFormExtensionTracer -count=1` passes and its schema assertion compares the field name list to exactly `[a c b]`
- `go doc ./modules/pact AdminFormExtension` prints the struct with Tabs, Fields, Hide, Permissions, Visible, Dir, Actions, PartialData, Load and Save
- `grep -c "AdminFormExtension" modules/pact/README.md docs/backend/admin-controllers.md` reports at least one line in each file
</acceptance_criteria>
<done>A contributor plugin's field C appears between owner fields A and B in the compiled schema, saves through the extension's Save inside the record's transaction and loads on show, without touching the owner's model; committed with README and docs.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Full placement rules, boot errors, permissions and the extension-owned branches (tabs, hide, widgets, partials, plugin field types, translatable fields)</name>
<files>modules/cabana/form_extensions.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/extension.go, modules/cabana/actions.go, modules/cabana/partial_render.go, modules/cabana/field_type.go, modules/cabana/field_ml.go, modules/cabana/form_extensions_smoke_test.go, modules/cabana/form_extensions_postgres_test.go</files>
<read_first>
- modules/cabana/form_extensions.go (from Task 2)
- modules/cabana/extension.go (compileExtension 60-165, compilePartials 200-251, checkWidgetTag 339)
- modules/cabana/actions.go (widgetAction 36-90, widgetField 356, runAction 322)
- modules/cabana/partial_render.go (refusedViewModel 287, service.partial 499)
- modules/cabana/field_type.go (pluginNotice 369, decodePluginFields 433, hydratePluginFields 696)
- modules/cabana/field_ml.go (liftMLValues 68, applyMLTranslations 163, hydrateMLRecord 190)
- modules/cabana/http.go (hiddenSchemaFields 850-866 and the schema/show handlers that call it)
- modules/cabana/field_type_smoke_test.go (ftPlugin, ftCompile fixtures 160-200)
- modules/party/registry.go (topoSort order, Requires)
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 1, Pitfalls 1-7)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C1)
</read_first>
<behavior>
- C After A on [A, B] gives [A, C, B]; C Before A gives [C, A, B]; C with no anchor gives [A, B, C] (end of its tab)
- New tab T2 After tab T1 and Before T3 gives tab order T1, T2, T3; a tab-less owner plus a new tab gives default, then the new tab
- Plugins P1 and P2 (P2 requires P1) both anchored After A give [A, C1, C2, B]; P2 anchored After C1 compiles; P1 anchored on a P2 field fails (P2 not in P1's Requires())
- Hide of owner field B removes it from schema and show; a tab whose fields are all hidden disappears
- Boot errors, each naming plugin, controller and name: unknown field anchor, unknown tab anchor, duplicate field name (vs owner and vs earlier extension), duplicate tab key, two tab keys whose labels localize to the same text, cycle among one extension's Before/After references, anchor outside Requires(), Before "default", Zone "outside" or "secondary" ("zone %q is not supported yet"), Hide of an unknown field, Hide of a field another field references through fill, dependsOn, trigger or preset, a fileupload extension field, an extension widget action name equal to an owner action, an extension partial name equal to an owner partial
- Permissions/Visible false: schema and show omit the extension's fields; a save with posted extension keys leaves stored values untouched and does not call Save
- Save returning AdminFormErrors{"c": ["validation.required"]} answers 422 with the field line, the owner row unchanged; Save returning Notices{"a": [...]} puts the notice in Meta.Notices; Save returning a plain error answers 500 and rolls back
- An extension field of a translatable plugin field type runs the type's Decode (notices kept) and hands Save map[locale]string; nothing is written to the owner's host column or translation rows
- An extension widget field (type: widget) with fill naming fields of the same extension compiles; its action dispatches to AdminFormExtension.Actions; an extension partial renders from the contributor's AdminFS Dir through PartialData and the view-model guard refuses a GORM model
- A controller no extension targets: CanonicalJSON of its form schema is byte-identical with and without the contributor plugin present
- An extension with no Tabs and no Fields but a Save (and optionally Load) hook is valid: its Save runs in the save transaction and may return AdminFormErrors or Notices naming owner fields (a save hook on another or its own controller's form)
</behavior>
<action>
Complete the D-91 compile pass and runtime per the behaviour list (D-91, D-101, D-100). Placement: canonicalise (P-02), then per plugin in party order insert tabs, then fields, with the anchor cursor (P-05); topologically sort one extension's own fields by their Before/After references and report a stalled sort as a cycle. Anchor resolution across extensions uses the field-to-plugin map and the contributor's `Requires()` (direct requirement only). Tab keys are raw `tab:` values (P-04); check duplicate raw keys and localized-label collisions for every locale the phrasebook loads, when the translator is published (RESEARCH Pitfall 1). Zone per P-03.
Extension-owned branches, keyed by the field-to-extension map compiled at boot: in `extension.go` check a widget tag against the contributing plugin's prefix, accept fill keys that are fields of the same extension, accept the contributor's field-type scripts or AdminClientAssets as the element source, and refuse a widget action name that clashes with the owner's; in `actions.go` dispatch `widgets/{field}` of an extension field to `AdminFormExtension.Actions` with the same permission checks plus the extension's Permissions; in `extension.go`/`partial_render.go` compile extension partials from the contributor's AdminFS `Dir` and render them through `AdminFormExtension.PartialData`, keeping `refusedViewModel`; in `field_type.go` run `Decode` for extension plugin-field-type values but return the decoded value to the extension instead of the host column, and on show run the extension `Load` first and the type's `Load` after it; in `field_ml.go` make `liftMLValues` skip extension ML fields (their per-locale values are lifted into the extension's Values as `map[string]string`).
Permissions and Visible: one helper `visibleExtensions(ctx, cc, principal)` used by the schema handler (next to `hiddenSchemaFields`), show and save; on save drop the keys of invisible extensions before anything else and do not call their Save (RESEARCH Pitfall 7).
Errors and notices per P-06.
Tests (written first, RED then GREEN): table tests in `form_extensions_smoke_test.go` (pure compile, no DB, `ftPlugin`-style fixtures) named `TestFormExtensionPlacement`, `TestFormExtensionBootErrors`, `TestFormExtensionTwoPluginsSameAnchor`, `TestFormExtensionOnePluginTwoControllers`, `TestFormExtensionUnextendedSchemaUnchanged`; Postgres cases in `form_extensions_postgres_test.go` named `TestFormExtensionSave` (rollback on error, 422 lines, notices, invisible extension skipped, translatable values as map, owner columns untouched). These are the plan's smoke coverage; the exhaustive unit matrix is plan 32-06.
Commit per logical change (compile pass; runtime branches), staging files by path. Before touching cabana check `git status --short modules/cabana` for other sessions' in-flight edits and leave them alone.
</action>
<verify>
<automated>go test -short ./modules/cabana -run 'TestFormExtension' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok|PASS)' | tail -40</automated>
<fails_when>a "--- FAIL" or "FAIL" line, "no tests to run", or any of TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionTwoPluginsSameAnchor, TestFormExtensionOnePluginTwoControllers, TestFormExtensionUnextendedSchemaUnchanged missing from the "--- PASS" lines</fails_when>
<automated>go test ./modules/cabana -run 'TestFormExtensionSave|TestFormExtensionTracer' -count=1 2>&1 | tail -5</automated>
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `go test -short ./modules/cabana -run TestFormExtensionBootErrors -v` shows one passing subtest per boot error listed in the behaviour block (at least 14 `--- PASS` subtest lines)
- `grep -n "is not supported yet" modules/cabana/form_extensions.go` prints at least one line (D-101 zone refusal)
- `grep -n "fileupload" modules/cabana/form_extensions.go` prints the refusal of fileupload extension fields (D-100)
- `TestFormExtensionUnextendedSchemaUnchanged` compares `CanonicalJSON()` bytes with and without the contributor and passes
- `git diff --name-only "$PLAN_START"..HEAD -- admin/src` prints nothing, where PLAN_START is the plan's start SHA recorded before Task 2's first commit (no SPA change)
- `go test ./modules/cabana -count=1` (Docker) exits 0
</acceptance_criteria>
<done>Every D-91 placement rule and boot error works and is covered by smoke tests; extension widgets, partials, plugin field types and translatable fields dispatch to the contributor; invisible extensions are inert on save; unextended forms are unchanged.</done>
</task>
<task type="auto">
<name>Task 4: Documentation, runnable example and the UI-SPEC C1 SPA backstop</name>
<files>modules/cabana/example_form_extension_test.go, modules/cabana/README.md, modules/pact/README.md, docs/backend/admin-controllers.md, docs/setup/coming-from-wintercms.md, admin/tests/form/FormView.test.ts</files>
<read_first>
- modules/cabana/example_action_extension_test.go (the docs snippet pattern, function ProfilesPlugin.AdminActionExtensions)
- docs/backend/admin-controllers.md (lines 497-530, the src= snippet convention)
- modules/cabana/README.md (Features line 58 on record-action extensions; API reference table near line 297)
- docs/setup/coming-from-wintercms.md (the extendFormFields mapping, if present)
- admin/tests/form/FormView.test.ts (existing fixtures and render helpers)
- admin/src/views/FormView.vue (tabs derivation 151-171) and admin/src/components/form/formState.ts
</read_first>
<action>
1. `example_form_extension_test.go`: an `ExtrasPlugin` (neutral name, D-90) whose `AdminActionExtensions()` adds a field After an owner field, a new tab After an owner tab, hides one owner field, and implements Load and Save against a side table; an `Example...` function with `// Output:` so `go test` runs it. Reference it from `docs/backend/admin-controllers.md` "Adding fields to another controller's form" with a `src=modules/cabana/example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions` block, then run `go run ./cmd/summer docs:sync`.
2. The docs section explains: stable names (field keys, raw tab keys, `default`), Before/After, end-of-tab default, tab placement, Hide, zones (only the tab zone, D-101), plugin order and the Requires() rule for anchors on another extension's fields, each boot error, Load and Save in the save transaction, AdminFormErrors and notices, Permissions and Visible on schema/show/save, that fileupload is refused in extensions (use a plugin field type with Upload instead), that the SPA renders the compiled order, and that an unextended form is unchanged. `modules/cabana/README.md` Features gains one bullet next to the record-action one; `modules/pact/README.md` documents each new type; `docs/setup/coming-from-wintercms.md` maps WinterCMS `extendFormFields` (addFields/addTabFields/removeField) to the new seam.
3. SPA backstop (UI-SPEC C1, no change under admin/src): in `admin/tests/form/FormView.test.ts` add two cases named "renders an extension field between two owner fields in compiled order" (schema fields a, c, b with c carrying the same tab; asserts DOM order a, c, b) and "a tab-less form with an extension tab renders Basic, then the new tab".
4. Run `scripts/check-admin-openapi.sh --check`: the payload shape is unchanged, so it must pass without regenerating; if it fails, the plan changed a payload by mistake: fix the code, not the generated files.
Commit docs/example as `docs(32-01): form extensions in admin-controllers, cabana and pact READMEs` and the test as `test(32-01): FormView renders compiled extension order`, staged by path.
</action>
<verify>
<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, "FAIL" in the test output, or docs:build reporting an unknown identifier, broken link or stale src= snippet</fails_when>
<automated>npm --prefix admin run test -- tests/form/FormView.test.ts 2>&1 | tail -15</automated>
<fails_when>non-zero exit, a "failed" count above zero, or the two new case titles missing from the passing output</fails_when>
<automated>scripts/check-admin-openapi.sh --check</automated>
<fails_when>non-zero exit (a committed OpenAPI or schema.d.ts file differs from a fresh generation)</fails_when>
<automated>go test -short ./modules/cabana -run 'Example' -count=1 2>&1 | tail -3</automated>
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions" docs/backend/admin-controllers.md` prints one line
- `grep -n "extendFormFields" docs/setup/coming-from-wintercms.md` prints at least one line mentioning the new seam
- `npm --prefix admin run test -- tests/form/FormView.test.ts` passes with the two new case titles
- `git diff --name-only "$PLAN_START"..HEAD -- admin/src` prints nothing (PLAN_START as in Task 3)
- `grep -niE "\bseo\b|fonoteka|plytarium|grzyb" modules/cabana/example_form_extension_test.go docs/backend/admin-controllers.md` prints nothing new from this plan (D-90)
</acceptance_criteria>
<done>The seam is documented with a runnable example, the docs checker is green, the SPA backstop proves compiled order rendering, and the admin OpenAPI is unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| contributing plugin → owner's admin form | compiled Go code from another plugin adds fields, hooks and partials to a form it does not own |
| admin browser → cabana save | posted values, including keys of extension fields the admin may not see |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-01 | Tampering | cabana save: extension values reaching the owner's model | high | mitigate | Extension values are lifted before ProjectWritableFields; BindWritableFields skips them; TestFormExtensionSave asserts the owner row and columns are untouched |
| T-32-02 | Elevation of privilege | cabana save: posted keys of an extension the admin may not see | high | mitigate | visibleExtensions applied in schema, show and save; invisible extensions' keys dropped and Save not called (RESEARCH Pitfall 7); covered by TestFormExtensionSave |
| T-32-03 | Information disclosure | extension partials | medium | mitigate | Partials render through PartialData with the existing refusedViewModel guard and sanitizePartialNodes; a GORM model view model is refused |
| T-32-04 | Denial of service | boot with a malformed extension | low | accept | A bad extension stops the boot with a clear error by design (D-91); no runtime path |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan; the admin test uses the existing locked node_modules (no npm ci unless npm ls reports an inconsistent tree) |
</threat_model>
<verification>
- `go vet ./... && go test -short ./...` green at every commit; `go test ./modules/cabana -count=1` green with Docker.
- `npm --prefix admin run test -- tests/form/FormView.test.ts` green; `scripts/check-admin-openapi.sh --check` green.
- `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check` green.
- No file under admin/src changed; no SEO concept or consuming application named in framework code, READMEs or docs (D-90).
</verification>
<success_criteria>
SC-1 holds in the framework: field C after A renders A, C, B; a tab placed between two tabs renders there; unknown anchors, duplicate names and anchor cycles stop the boot with a clear error. The seam is strictly additive and documented.
</success_criteria>
## Artifacts this phase produces (plan 01)
- `pact.AdminActionExtension.Form` (new field), `pact.AdminFormExtension`, `pact.AdminFormTab`, `pact.AdminFormField`, `pact.AdminFormLoad`, `pact.AdminFormSave`, `pact.AdminFormSaveResult`, `pact.AdminFormErrors` (+ `Error()`), `pact.AdminFormZoneTabs`, `pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`
- `modules/cabana/form_extensions.go`: `compileFormExtensions`, `visibleExtensions`, the extension-owned field map
- Tests: `TestFormExtensionTracer`, `TestFormExtensionSave`, `TestFormExtensionPlacement`, `TestFormExtensionBootErrors`, `TestFormExtensionTwoPluginsSameAnchor`, `TestFormExtensionOnePluginTwoControllers`, `TestFormExtensionUnextendedSchemaUnchanged`, the example in `modules/cabana/example_form_extension_test.go`
- Docs: "Adding fields to another controller's form" in `docs/backend/admin-controllers.md`; README entries in `modules/pact/README.md` and `modules/cabana/README.md`
- SPA test cases in `admin/tests/form/FormView.test.ts`
<output>
Create `.planning/phases/32-seo-foundation/32-01-SUMMARY.md` when done. Record the Task 1 answer, the commit SHA at the plan's start (the phase base used by scripts/check-phase32.sh in plan 32-05) and every commit SHA.
</output>

View File

@@ -0,0 +1,322 @@
---
phase: 32-seo-foundation
plan: 01
subsystem: admin
tags: [cabana, pact, form-extensions, d-91, yaml, admin-spa]
requires:
- phase: 12.x/19.x admin (cabana)
provides: compiled fields.yaml forms, plugin field types, widgets, partials, AdminActionExtension record actions
provides:
- "pact.AdminActionExtension.Form with pact.AdminFormExtension (fields document via Fields path or FieldsYAML bytes, Hide, Permissions, Visible, Dir, Actions, PartialData, Load, Save)"
- "pact.AdminFormLoad, AdminFormSave, AdminFormSaveResult, AdminFormErrors, AdminFormZoneTabs/Outside/Secondary"
- "cabana compileFormExtensions: boot-time placement (field and tab anchors, anchor cursor, Requires() rule, cycles, Hide, D-101 zones) with clear boot errors"
- "cabana runtime: extension values lifted before any owner path, Save in the record's save transaction, Load on show and save, visibility on schema/show/save/widgets/partials"
affects: [32-02, 32-03, 32-04, 32-05, 32-06, sm-seo-plugin]
actuals:
tokens: 34400
tasks: 4
commits: 5
plan_head_before: 9868838fe3d01e07d578f2b2dd557d4296c48e10
plan_head_after: 9ae326908fed1aea0fd5e251e402cc1f01322d9e
tech-stack:
added: []
patterns:
- "Form extension fields document: fields.yaml format plus before:/after:/zone: per field and a top-level tabs: list, compiled through decodeFieldMapping"
- "Extension-owned field registry (CompiledController.extensionFields) keys every owner-aware branch: lift, decode, hydrate, widgets, partials"
- "One visibility helper (visibleExtensions) for schema, show and save"
key-files:
created:
- modules/cabana/form_extensions.go
- modules/cabana/form_extensions_postgres_test.go
- modules/cabana/form_extensions_smoke_test.go
- modules/cabana/example_form_extension_test.go
modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/contracts.go
- modules/cabana/http.go
- modules/cabana/crud.go
- modules/cabana/actions.go
- modules/cabana/partial_render.go
- modules/cabana/field_type.go
- modules/cabana/field_ml.go
- modules/cabana/README.md
- docs/backend/admin-controllers.md
- docs/setup/coming-from-wintercms.md
- admin/tests/form/FormView.test.ts
key-decisions:
- "D-91 Go shape (Task 1, answered by Jakub before dispatch): yaml-document. An extension carries a fields.yaml-format document whose fields take before:/after:/zone: and whose top-level tabs: list declares new tabs; pact.AdminFormTab and pact.AdminFormField are not published (redundant with the document)"
- "Declaration form: AdminFormExtension.Fields is a path in the contributing plugin's AdminFS resolved with assetPath (like SettingsItem.Form and config_form.yaml form:); AdminFormExtension.FieldsYAML carries the document bytes for fields built at boot (D-104 per-type tabs); setting both stops the boot"
- "A field with an anchor and no tab: joins the anchor's tab; a field's tab: must be an owner tab or one declared under tabs: (by itself or a required plugin)"
- "Hide is static (every administrator); hidden owner fields also leave the owner's bindings, so show and save skip them"
- "Extension field types: text, textarea, number, checkbox, switch, dropdown with listed options, markdown, mltext, mltextarea, mlmarkdown, plugin field types, widget, partial; fileupload refused per D-100; value fields need Save"
- "Save runs after the owner's FormAfterCreate/FormAfterUpdate hooks inside the save transaction, on every create and update of a visible extension; Load runs on show and in the save response"
- "Translatable extension values without a TranslationWriter are keyed by the admin's configured locale"
- "Localized tab-label collisions are checked by checkFormExtensionTabLabels in Activate (where the translator is available), not inside compileFormExtensions"
- "A widget element's script comes from the contributor's field type scripts or its plugin-level pact.AdminClientAssets (added to the target form's assets)"
patterns-established:
- "Form extension boot errors: cabana: plugin <id>: controller \"<ctl>\": <field|tab|Hide> <name>: <reason>"
- "Owner-aware branches check cc.extensionOf(name) before treating a form field as the owner's"
requirements-completed: [SC-1, SC-5, D-91, D-101, D-90, D-98, D-100, D-104]
coverage:
- id: D1
description: "Plugin B adds field C after A on plugin A's form: schema lists a, c, b; Save stores c in the contributor's table in the record's transaction; show serves c; owner model untouched"
requirement: "SC-1"
verification:
- kind: integration
ref: "modules/cabana/form_extensions_postgres_test.go#TestFormExtensionTracer"
status: pass
human_judgment: false
- id: D2
description: "Placement rules: before/after, end of tab, anchor tab inheritance, own-field anchors, new tabs between tabs, tab-less form plus new tab, regrouping, Hide, span kept"
requirement: "D-91"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionPlacement"
status: pass
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionTwoPluginsSameAnchor"
status: pass
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionOnePluginTwoControllers"
status: pass
human_judgment: false
- id: D3
description: "Boot errors (22 cases plus localized tab-label collision) name plugin, controller and offending name; D-101 zones and D-100 fileupload refused"
requirement: "D-101"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionBootErrors"
status: pass
human_judgment: false
- id: D4
description: "Save transaction semantics: AdminFormErrors 422 with rollback, plain error 500 with rollback, notices, hidden owner field, translatable locale maps, invisible extension inert, create"
requirement: "D-91"
verification:
- kind: integration
ref: "modules/cabana/form_extensions_postgres_test.go#TestFormExtensionSave"
status: pass
human_judgment: false
- id: D5
description: "Extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData with the GORM view-model guard, invisible extensions offer neither"
requirement: "D-91"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionWidgetsAndPartials"
status: pass
human_judgment: false
- id: D6
description: "Strictly additive: a form no extension lays out compiles byte-identically (CanonicalJSON), whole repo vet and tests green, admin OpenAPI unchanged"
requirement: "SC-5"
verification:
- kind: unit
ref: "modules/cabana/form_extensions_smoke_test.go#TestFormExtensionUnextendedSchemaUnchanged"
status: pass
- kind: other
ref: "go vet ./... && go test -short ./... && go test ./modules/cabana -count=1"
status: pass
- kind: other
ref: "scripts/check-admin-openapi.sh --check"
status: pass
human_judgment: false
- id: D7
description: "SPA renders compiled order with no admin/src change (UI-SPEC C1 backstop)"
verification:
- kind: unit
ref: "admin/tests/form/FormView.test.ts#form extensions in compiled order (UI-SPEC C1, D-91)"
status: pass
human_judgment: false
- id: D8
description: "Docs: admin-controllers section with synced ExtrasPlugin snippet, runnable Example_formExtension, pact and cabana READMEs, WinterCMS extendFormFields mapping; no SEO concept or consuming app named (D-90)"
requirement: "D-90"
verification:
- kind: other
ref: "go run ./cmd/summer docs:build --check && go test ./cmd/summer -run TestDocsTree"
status: pass
- kind: unit
ref: "modules/cabana/example_form_extension_test.go#Example_formExtension"
status: pass
human_judgment: false
- id: D9
description: "The published pact shape (yaml-document form) fits the SEO plugin's needs in plans 32-03/32-04 (SEO tab per type, per-type settings tabs via FieldsYAML)"
verification: []
human_judgment: true
rationale: "One-way framework API; its fitness for sm-seo-plugin is proven only when plan 32-03 dogfoods it"
duration: 32min
completed: 2026-10-26
status: complete
---
# Phase 32 Plan 01: Cross-plugin admin form extensions (D-91) Summary
**A plugin now places fields and tabs inside another plugin's admin form from a fields.yaml-format document with before:/after:/zone: anchors and a tabs: list, compiled once at boot, and loads and saves the values itself inside the record's save transaction without touching the owner's model.**
## Task 1 answer (D-91 Go shape)
Answered by Jakub before dispatch: **yaml-document**. Recorded shape:
- `pact.AdminActionExtension` gains `Form *pact.AdminFormExtension` as its last field.
- `pact.AdminFormExtension{Fields string; FieldsYAML []byte; Hide, Permissions []string; Visible func(ctx) bool; Dir string; Actions []pact.AdminAction; PartialData func(ctx, name, record) (any, error); Load func(ctx, pact.AdminFormLoad) (map[string]any, error); Save func(ctx, pact.AdminFormSave) (pact.AdminFormSaveResult, error)}`.
- `Fields` is a path in the contributing plugin's AdminFS, resolved with `assetPath` exactly like `pact.SettingsItem.Form` and `config_form.yaml` `form:` (so `extensions/posts.yaml`, `$/vendor/plugin/...` and `~/plugins/vendor/plugin/...` work). `FieldsYAML` is the document itself, which is how a plugin generates fields at boot (D-104 per-type tabs). At most one may be set.
- Document format: `fields:` (every fields.yaml key, plus `before:`, `after:`, `zone:` per field) and `tabs:` (a list of `name`, `before`, `after`, `zone`). The placement keys are taken out of each field node before the rest compiles through the same `decodeFieldMapping`/`compileFieldNode` path as an owner's fields.yaml.
- Kept as planned: `AdminFormLoad{DB, Controller, Record, ID}`, `AdminFormSave{DB, Controller, Record, ID, Created, Values}`, `AdminFormSaveResult{Notices}`, `AdminFormErrors` (+`Error()`), `AdminFormZoneTabs/Outside/Secondary` (now the `zone:` values).
- Not published: `pact.AdminFormTab` and `pact.AdminFormField`. In the yaml-document form, tabs and fields live in the document.
- Plan 32-03 should use this shape instead of the go-placement sketch in its `<interfaces>`. `AdminFormField{Config}` becomes a `fields:` entry, and per-type tabs become a `FieldsYAML` document built at boot.
**Plan start SHA (phase base for scripts/check-phase32.sh in plan 32-05): `9868838fe3d01e07d578f2b2dd557d4296c48e10`**
## Performance
- **Duration:** 32 min
- **Started:** 2026-10-26T20:34:49Z
- **Completed:** 2026-10-26T21:07:01Z
- **Tasks:** 4 (Task 1 resolved before dispatch)
- **Files modified:** 17
## Accomplishments
- Additive pact API for form extensions, with every boot error and runtime rule documented on the type
- `compileFormExtensions` covers:
- placement: field and tab anchors, end of tab, an anchored field joining its anchor's tab
- plugin order through an anchor cursor
- the `Requires()` rule for anchors on another plugin's additions, and cycle detection inside one document
- Hide, with checks on fields that reference the hidden one
- D-101 zones and D-100 fileupload refusal
- regrouping only for forms an extension lays out
- Owner-aware branches:
- widget tags use the contributor's prefix and run the extension's own `Actions`
- partials render from the contributor's `Dir` through `PartialData`
- plugin field types run `Decode` before `Save` and their `Load` after the extension's
- translatable values reach `Save`/`Load` as locale maps
- ML and plugin owner paths skip extension fields
- Save path:
- extension keys leave the body before any owner path reads them
- `Save` runs in the record's transaction after the owner row and its after hooks
- `AdminFormErrors` answers 422; any other error is a 500. Both roll everything back
- notices join `meta.notices`
- Permissions/Visible apply to schema, show, save, widget actions and partials through one helper
- Docs (admin-controllers section with synced snippet, cabana/pact READMEs, WinterCMS mapping), runnable example, SPA backstop tests
## Task Commits
1. **Task 1: Confirm the Go shape**: no commit (answered before dispatch: yaml-document)
2. **Task 2: Tracer (c after a through the admin API)**: `0819442` (feat)
3. **Task 3: Full placement rules, boot errors, owner-aware branches (TDD)**:
- RED `4bb22a8` (test)
- GREEN `e7423bd` (feat)
4. **Task 4: Docs, example, SPA backstop**:
- `fa1af69` (docs)
- `9ae3269` (test)
Tracer feedback gate: interactive, end-of-phase mode, automated-only verify. It re-ran green (TestFormExtensionTracer, `go vet ./...`, `go test -short ./...`) before expanding.
## TDD Gate Compliance
- RED `4bb22a8` before GREEN `e7423bd`.
- RED evidence (TAP transcription of `go test -json`, target `TestFormExtensionBootErrors`): `RED_EVIDENCE_OK`. Failing in RED: Placement, TwoPluginsSameAnchor, BootErrors, WidgetsAndPartials.
- `TestFormExtensionOnePluginTwoControllers` and `TestFormExtensionUnextendedSchemaUnchanged` already passed in RED. They guard invariants the tracer already met.
- An initial RED run hit fixture errors (an owner model without the test's columns) and was fixed before the RED commit.
- No REFACTOR commit.
## Files Created/Modified
- `modules/pact/capabilities.go`: `Form` field, `AdminFormExtension` and the Load/Save/result/error types, zone constants
- `modules/pact/README.md`: rows for each new type
- `modules/cabana/form_extensions.go`: document parsing, placement layout, per-kind compile checks, tab-label check, permission validation, runtime (lift, Save, Load, visibility)
- `modules/cabana/contracts.go`: `formExtensions`, `extensionFields`, `extensionPartials` on `CompiledController`
- `modules/cabana/http.go`: wiring in Activate and the form schema filter
- `modules/cabana/crud.go`: save and show wiring; `BindWritableFields` skips extension fields
- `modules/cabana/actions.go`, `partial_render.go`: extension widget and partial dispatch
- `modules/cabana/field_type.go`, `field_ml.go`: owner paths skip extension fields
- Tests:
- `form_extensions_smoke_test.go`, `form_extensions_postgres_test.go`
- `example_form_extension_test.go`
- `admin/tests/form/FormView.test.ts`
- Docs: `docs/backend/admin-controllers.md`, `docs/setup/coming-from-wintercms.md`, `modules/cabana/README.md`
## Decisions Made
See `key-decisions` in the frontmatter. The main one is the Task 1 yaml-document shape and its `Fields`/`FieldsYAML` declaration form, explained above.
## Acceptance-criteria adjustments (yaml-document form)
- Task 2, `go doc ./modules/pact AdminFormExtension`: the plan expected `Tabs` and `Fields` struct fields.
- Now prints `Fields, FieldsYAML, Hide, Permissions, Visible, Dir, Actions, PartialData, Load, Save`.
- Tabs are a `tabs:` list in the document, not a struct field.
- `grep "Form \*AdminFormExtension"`, the `compileFormExtensions` signature and its position before `compileActionExtensions`: unchanged, all pass.
- Task 3 boot-error criterion (14 or more `--- PASS` subtests): 23 subtests pass.
- The zone errors come from the document's `zone:` key, not a struct field.
- Artifact `contains: "Form *AdminFormExtension"`: holds.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `modules/cabana/contracts.go` modified (not in files_modified)**
- **Found during:** Task 2
- **Issue:** The extension-owned field registry has to live on `CompiledController`.
- **Fix:** Added the unexported fields `formExtensions`, `extensionFields` and `extensionPartials`.
- **Committed in:** `0819442`, `e7423bd`
**2. [Rule 3 - Blocking] The RED commit includes a no-op `checkFormExtensionTabLabels`**
- **Found during:** Task 3 RED
- **Issue:** The tab-label test needs the function to exist, or the build fails (INVALID_RED).
- **Fix:** Declared it returning nil in the RED commit and implemented it in GREEN.
- **Committed in:** `4bb22a8`
**3. [Rule 1 - Test fixture] Smoke owners use their own model, `feWide`; partial templates read `.Data`**
- **Found during:** Task 3 RED and GREEN
- **Fix:** Test-only changes.
**4. [Plan detail] The example lives in package `cabana` as `Example_formExtension`**
- **Issue:** The compile path is unexported, so an external `cabana_test` example could not show the compiled order.
- **Fix:** The docs snippet (`ExtrasPlugin.AdminActionExtensions`) is unaffected.
**5. [Plan detail] Commit split**
- Task 3's compile pass and runtime share `form_extensions.go`, so GREEN is one feat commit instead of two.
- The Task 4 docs commit carries the planned message ("... and pact READMEs"), but the pact README rows had already landed in the Task 2 commit, as the documentation rule requires.
**6. [Rule 2 - Missing critical] Permission codes of extensions and their widget actions are validated at boot**
- **Fix:** Added `validateFormExtensionPermissions` after `compileContributions`, mirroring the controller checks.
- **Committed in:** `e7423bd`
---
**Total deviations:** 6 (2 blocking, 1 test fixture, 2 plan detail, 1 missing critical).
**Impact on plan:** Behaviour is as planned; only the declaration form changed (Task 1). No scope creep.
## Issues Encountered
None.
## Threat model
- T-32-01 (extension values reaching the owner's model): mitigated. Values are lifted first, `BindWritableFields` skips extension fields, and ML/plugin owner paths skip them. Covered by TestFormExtensionTracer and TestFormExtensionSave.
- T-32-02 (posted keys of an invisible extension): mitigated. `visibleExtensions` applies on schema, show, save, widgets and partials. Covered by TestFormExtensionSave and TestFormExtensionWidgetsAndPartials.
- T-32-03 (partial disclosure): mitigated. `refusedViewModel` guards extension partials. Covered by TestFormExtensionWidgetsAndPartials (leak case).
- T-32-SC: no packages installed.
## User Setup Required
None.
## Next Phase Readiness
- Plans 32-02 through 32-06 can build on the seam.
- Plan 32-03 must use the yaml-document shape recorded above: the SEO tab is a `FieldsYAML` or `Fields` document with `tabs: [{name: golem15.seo::lang.tab.seo}]`, and the per-type settings tabs are `FieldsYAML` built at boot.
- `AdminFormSave` has no `Changes` yet; plan 32-02 adds it.
## Self-Check: PASSED
- All key files exist on disk, and commits 0819442, 4bb22a8, e7423bd, fa1af69 and 9ae3269 are present in `git log`.
- Commands run green:
- `go vet ./...` and `go test -short ./...`
- `go test ./modules/cabana -count=1` (Docker), plus TestFormExtensionTracer and TestFormExtensionSave on their own
- `docs:build --check` and `TestDocsTree`
- the FormView tests and `typecheck`
- `scripts/check-admin-openapi.sh --check`
- `git diff --name-only 9868838..HEAD -- admin/src` is empty, and the D-90 grep finds nothing.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-26*

View File

@@ -0,0 +1,354 @@
---
phase: 32-seo-foundation
plan: 02
type: execute
wave: 2
depends_on: ["32-01"]
files_modified:
- modules/pact/capabilities.go
- modules/pact/record_events.go
- modules/pact/content.go
- modules/pact/README.md
- modules/cabana/record_events.go
- modules/cabana/crud.go
- modules/cabana/reorder.go
- modules/cabana/relation_child.go
- modules/cabana/record_events_postgres_test.go
- modules/cabana/README.md
- modules/signpost/signpost.go
- modules/signpost/content.go
- modules/signpost/content_test.go
- modules/signpost/README.md
- docs/plugins/extending.md
- docs/plugins/menu-item-types.md
- docs/backend/admin-controllers.md
- ../sm-journal-plugin/classes/menu_item_types.go
- ../sm-journal-plugin/classes/content_descriptors.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_helpers.go
- ../sm-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-journal-plugin/README.md
- ../sm-journal-plugin/record_events_test.go
- ../sm-journal-plugin/content_descriptors_test.go
autonomous: false
requirements: [SC-2, SC-3, D-92, D-93, D-96, D-102, D-58, D-90, D-18, D-20c, D-11]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 4
confidence: low
must_haves:
truths:
- "A subscriber learns a record's new public URL after a slug change: cabana's admin save fires pact.RecordSaved after commit, and signpost's content descriptor for that model describes the record with the new per-locale URL (D-92, D-93, SC-3)"
- "pact.RecordSaved carries the controller ID, the model's morph type, the record id, Created, the request content locale and the changed attributes with old and new values per locale; translated slugs appear as per-locale changes (D-92)"
- "Record events fire after commit only: a rolled-back save fires nothing, and a listener error is logged and never fails the save (D-92)"
- "cabana emits RecordSaved/RecordDeleted from create, update, delete, bulk delete, tree reorder and relation-manager child writes; plugin code that writes directly calls cabana.EmitRecordSaved/EmitRecordDeleted (D-92)"
- "Journal's /_journal/api/v1 Store, Update and Destroy emit the same events, so a slug changed through MCP is visible to subscribers (D-102, SC-3)"
- "pact.MenuItemType gains an optional Content descriptor (controller ID, model, Records, Describe, Match); a type without Content behaves exactly as before (D-93)"
- "signpost lists content types, maps a controller ID or a model to its type, describes a record with the host's layered pattern, pages records, and matches a public path to a record with Exact false when a segment (category, locale prefix) does not match the record (D-93, D-11, D-20c)"
- "Journal registers descriptors for journal-post, journal-category and the new journal-tag type and imports neither sm-seo-plugin nor sm-sitemap-plugin (D-93, D-96, D-102, SC-2)"
- "Journal's post API and post descriptor resolve translated slugs for the request locale (D-58, D-96)"
- "AdminFormSave carries the same Changes list, so an extension's Save sees a slug change inside the save transaction (D-92, D-91)"
- "The framework change is strictly additive (D-106): new optional fields, new types and new functions only, and no method added to an existing interface; existing pact, cabana and signpost signatures and behaviour are unchanged, and journal, sitemap and the demo app build unchanged against it"
prohibitions:
- statement: "No breaking change to existing framework API: MenuItemType, AdminFormSave and every existing cabana and signpost function keep their signatures and behaviour; a plugin that registers no Content descriptor and no listener sees no difference"
status: resolved
verification: test
- statement: "Content plugins never import sm-seo-plugin or sm-sitemap-plugin; the framework names no SEO concept and no consuming application (D-90, D-93)"
status: resolved
verification: test
- statement: "A record event must never fire for a write that did not commit"
status: resolved
verification: test
artifacts:
- path: "modules/pact/record_events.go"
provides: "RecordChange, RecordSaved, RecordDeleted"
contains: "type RecordSaved struct"
- path: "modules/pact/content.go"
provides: "ContentDescriptor and its request/result types"
contains: "type ContentDescriptor struct"
- path: "modules/cabana/record_events.go"
provides: "EmitRecordSaved, EmitRecordDeleted and the save snapshot diff"
contains: "func EmitRecordSaved("
- path: "modules/signpost/content.go"
provides: "ContentTypes, ContentType, ContentTypeForController, ContentTypeForModel, Describe, Records, MatchPath, MatchPattern"
contains: "func (r *Registry) MatchPath("
- path: "../sm-journal-plugin/classes/content_descriptors.go"
provides: "post, category and tag descriptors"
contains: "pact.ContentDescriptor"
key_links:
- from: "modules/cabana/crud.go"
to: "modules/cabana/record_events.go"
via: "CRUDService.save, Delete and BulkDelete register the event with lagoon.AfterCommit on the save transaction"
pattern: "emitRecordSaved|EmitRecordSaved"
- from: "../sm-journal-plugin/controllers/api/posts.go"
to: "modules/cabana/record_events.go"
via: "Store/Update/Destroy call cabana.EmitRecordSaved/EmitRecordDeleted next to purgeSiteCache"
pattern: "cabana\\.EmitRecord(Saved|Deleted)"
- from: "../sm-journal-plugin/classes/menu_item_types.go"
to: "modules/pact/content.go"
via: "Content: &pact.ContentDescriptor{...} on journal-post, journal-category, journal-tag"
pattern: "Content: +&pact\\.ContentDescriptor"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan delivers the second and third framework seams and Journal's adoption: plugins hear about saved and deleted records, and content types describe their records and URLs.
<objective>
Implement D-92 record events and D-93 content descriptors in the framework, and adopt them in Journal per D-96 and D-102:
- pact gains `RecordChange`, `RecordSaved`, `RecordDeleted` (fired as pointers through `app.Events` after commit, like `pact.MCPSubjectChanged`), `AdminFormSave.Changes`, `MenuItemType.Content` and the `ContentDescriptor` types;
- cabana emits the events from every admin write path and exports `EmitRecordSaved`/`EmitRecordDeleted` for direct writers;
- signpost gains content-type lookups, `Describe`, `Records`, `MatchPath` and the pure `MatchPattern`;
- Journal registers descriptors for posts, categories and a new `journal-tag` type, emits events from its `/_journal/api/v1` write handlers (D-102), and fixes the translated-slug lookup (D-58), the D-96 fix that belongs to Phase 32. D-96's H1 conversion (D-50) and schema types (D-43) are Phase 33 (see the boundary in plan 32-06).
Purpose: SC-3 needs a slug change from the admin, MCP or an import to reach the SEO plugin; SC-2 needs Journal posts, categories and tags to take part without importing sm-seo-plugin; D-11 and D-20c need a public path mapped to its record. The framework names no SEO concept (D-90).
Output: the pact types, `modules/cabana/record_events.go`, `modules/signpost/content.go`, docs, and the Journal changes committed in `../sm-journal-plugin` (branch main). Which plan implements each decision of the phase, and what Phase 33 takes, is in `32-DECISIONS-MAP.md`.
**Planner decisions recorded here (Claude's discretion: the exact Go shape, CONTEXT):**
- **P-07 (event shape):** `type RecordChange struct { Attribute, Locale string; Old, New any }`; `type RecordSaved struct { Controller, Model, ID, Locale string; Created bool; Changes []RecordChange }`; `type RecordDeleted struct { Controller, Model, ID string }`. `Model` is `lagoon.MorphType(db, record)` (the attach.Owner morph name, else the table name), so the event and the descriptor agree. `ID` is the primary key as text. `Locale` is the request's content locale (towel). On create, `Changes` lists every written column with a nil Old. Fired and listened to as pointers (`app.Events.Listen("vendor.plugin", func(ctx, *pact.RecordSaved) error)`).
- **P-08 (helpers):** `cabana.EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved)` and `cabana.EmitRecordDeleted(ctx, app, tx, ev pact.RecordDeleted)` register the firing with `lagoon.AfterCommit(ctx, tx, ...)`; with a nil tx they fire immediately. A nil app or bus is a no-op. A listener error is logged with `moduleLog.WarnContext` and never returned (the `fireSubjectChanged` rule).
- **P-09 (descriptor shape):** `MenuItemType.Content *ContentDescriptor` (json:"-"); `type ContentDescriptor struct { Controller, Model string; Records func(ctx, ContentList) (ContentPage, error); Describe func(ctx, ContentDescribe) (*ContentRecord, error); Match func(ctx, ContentMatch) (*ContentMatchResult, error) }`; `ContentList{Pattern, After, UnprefixedLocale string; Limit int}`; `ContentPage{Records []ContentRecord; Next string}`; `ContentDescribe{ID, Pattern, UnprefixedLocale string}`; `ContentRecord{ID string; Published bool; LastMod *time.Time; Locales []ContentLocale}`; `ContentLocale{Locale, URL, Title, Description, Image string; Words *int}` (URL "" when the locale or the host has no public page); `ContentMatch{Path, Pattern, UnprefixedLocale string}`; `ContentMatchResult{ID, Locale string; Exact bool}`. `Model` is required and `Describe` is required when `Content` is set; `Records` and `Match` are optional; two types with the same `Content.Model` or `Content.Controller` stop the boot. Descriptor funcs read through `cabana.TxFromContext(ctx)` when a transaction is in ctx, so an extension's Save sees the new slug.
- **P-10 (signpost API):** `(*Registry).ContentTypes() []pact.MenuItemType` (registration order), `ContentType(key)`, `ContentTypeForController(id)`, `ContentTypeForModel(model)` (each `(pact.MenuItemType, bool)`), `Describe(ctx, key, id, unprefixedLocale string) (*pact.ContentRecord, error)` and `Records(ctx, key, after string, limit int, unprefixedLocale string) (pact.ContentPage, error)` (both layer the host's pattern override), `MatchPath(ctx, path, unprefixedLocale string) (key string, res *pact.ContentMatchResult, err error)` (types in registration order; the first Exact match wins, else the first non-exact match), and the pure `signpost.MatchPattern(pattern, path string) (map[string]string, bool)` content types use to read placeholder values.
- **P-11 (tags):** `journal-tag` with `DefaultPattern "/blog/tag/:slug"` (the vue-journal-layer default route), placeholders `:slug`, `:id`. A host whose site has no tag pages sets the config key `golem15.journal.tag_pages: false` (default true): tag descriptors then answer an empty URL and tag paths never match (RESEARCH Pitfall 9).
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-RESEARCH.md
@.planning/phases/32-seo-foundation/32-PATTERNS.md
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
@CLAUDE.md
@modules/pact/capabilities.go
@modules/cabana/subject_events.go
@modules/signpost/signpost.go
<interfaces>
From modules/cabana/subject_events.go (lines 25-44): fireSubjectChanged(ctx, app, kind, adminIDs...) — nil app/bus no-op; tx, _ := TxFromContext(ctx); lagoon.AfterCommit(ctx, tx, func(ctx, _ *gorm.DB){ event := &pact.MCPSubjectChanged{...}; if err := app.Events.Fire(ctx, event); err != nil { moduleLog.WarnContext(...) } }).
From modules/lagoon/transaction.go: func AfterCommit(ctx context.Context, db *gorm.DB, fn func(ctx context.Context, db *gorm.DB)) — inside a transaction it runs after commit and never after rollback; anywhere else fn runs immediately.
From modules/lagoon/deferred.go: func MorphType(db *gorm.DB, model any) (string, error).
From modules/festival/bus.go: func (b *Bus) Listen[T any](owner string, fn func(context.Context, T) error); func (b *Bus) Fire[T any](ctx context.Context, event T) error — synchronous.
From modules/signpost/signpost.go: Registry; Activate[P](app, plugins) (40); add(owner, t) validation (68-89, message shape "signpost: menu item type %q of plugin %s has no Resolve"); Types() (132); Type(key) (140); Pattern(key, itemOverride) (161); Resolve(ctx, key, reference, itemPattern, nesting, unprefixedLocale) (200).
From modules/pact/capabilities.go: MenuItemType (798-808) with func fields tagged json:"-"; MenuItemResolve; ResolvedMenuItem.Alternates.
From ../sm-journal-plugin/classes/menu_item_types.go: MenuType* key consts (21-27), label consts (30-35), DefaultPostPattern "/blog/:slug", DefaultCategoryPattern "/blog/category/:slug", postPlaceholders [":slug", ":category", ":id", ":year", ":month", ":day"], menuTypes{app} methods resolvePost (194), resolveCategory (472), loadMenuLocales(ctx, db, unprefixed) (655).
From ../sm-journal-plugin/controllers/api/posts.go: Store/Update/Destroy call purgeSiteCache(r, app, post) → cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, ...) (~400); applyPostUpdate (~268).
From ../sm-journal-plugin/controllers/api/posts_helpers.go: findPostBySlugOrID(db, slug) (55) matches only the default-locale slug, then a numeric id.
From ../sm-journal-plugin/models/post.go: Translatable() includes "slug", TranslatableIndexes() {"slug"}; MorphName() `Golem15\Journal\Models\Post`.
Controller IDs: golem15.journal.posts, golem15.journal.categories, golem15.journal.tags.
</interfaces>
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Confirm the Go shapes of the D-92 record events and the D-93 content descriptors (published framework API)</name>
<decision>Do P-07 to P-10 become the published shapes of the record events (D-92) and the content descriptors (D-93)?</decision>
<context>Both seams are confirmed decisions; their Go shapes are Claude's discretion and become framework API at the phase's release tag. D-92 is costly to change (subscribers in shared plugins depend on the event shape) and D-93 is costly (every content plugin implements the functions). The shapes are written out in this plan's objective (P-07 event types and helpers, P-09 descriptor types, P-10 signpost functions); everything is additive (new types, a new optional MenuItemType field, a new AdminFormSave field).</context>
<options>
<option id="as-planned">
<name>Adopt P-07 to P-10 as written</name>
<pros>Event Model equals the morph type the descriptor declares, so mapping an event to its content type is one lookup; descriptors read the save transaction from ctx, so an extension's Save sees the new URL inside the same transaction; MatchPath gives D-11 ownership and D-20c strict URLs one entry point</pros>
<cons>Match is a fourth descriptor function beyond D-93's list (records, lastmod, published, defaults); it is optional</cons>
</option>
<option id="no-match">
<name>As planned without Match/MatchPath; the SEO plugin matches paths by enumerating Records</name>
<pros>Smaller published surface</pros>
<cons>Strict URLs and URL ownership need a full record scan or a URL index kept by the SEO plugin; translated slugs and category segments are harder to check</cons>
</option>
<option id="amend">
<name>As planned with Jakub's renames or additions</name>
<pros>Names fixed before publication</pros>
<cons>The executor applies the amendments to every task</cons>
</option>
</options>
<resume-signal>Answer as-planned, no-match, or amend with the changes; the executor records the answer in 32-02-SUMMARY.md</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end "a Journal post's slug changes in the admin, a subscriber gets RecordSaved and describes the new URL" — one path only</name>
<reversibility rating="costly">Subscribers in shared plugins depend on the event shape and every content plugin implements the descriptor functions (D-92, D-93).</reversibility>
<files>modules/pact/record_events.go, modules/pact/content.go, modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/record_events.go, modules/cabana/crud.go, modules/cabana/README.md, modules/signpost/content.go, modules/signpost/signpost.go, modules/signpost/README.md, ../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/record_events_test.go</files>
<read_first>
- modules/cabana/subject_events.go (whole file)
- modules/cabana/crud.go (save 736-944: loadRecord, Fill, tx.Save/Create, applyMLTranslations; the extension Save call added in plan 32-01)
- modules/lagoon/transaction.go (AfterCommit 95-130) and modules/lagoon/deferred.go (MorphType)
- modules/signpost/signpost.go (whole file, 240 lines)
- modules/pact/capabilities.go (MenuItemType 782-860; AdminFormSave from plan 32-01)
- ../sm-journal-plugin/classes/menu_item_types.go (19-160, resolvePost 194-272, loadMenuLocales 655)
- ../sm-journal-plugin/admin_harness_test.go (how Journal tests drive cabana over HTTP with Postgres)
- The Task 1 answer recorded by the executor
</read_first>
<action>
Apply the Task 1 answer (the text assumes as-planned). Per D-92 and D-93:
1. pact: `record_events.go` with RecordChange, RecordSaved, RecordDeleted and doc comments stating after-commit, pointer firing, the Model/ID conventions (P-07); `content.go` with the P-09 types; in `capabilities.go` add `Content *ContentDescriptor` with `json:"-"` as the last field of `MenuItemType` and `Changes []RecordChange` as the last field of `AdminFormSave`, extending their doc comments.
2. cabana `record_events.go`: `EmitRecordSaved`, `EmitRecordDeleted` (P-08) and an unexported `recordSnapshot` that captures host column values after `loadRecord` and before Fill. In `crud.go` save (update path only for the tracer): diff the snapshot against the saved row, build `Changes`, fill `AdminFormSave.Changes` before extension Save calls, and call `EmitRecordSaved(ctx, app, tx, pact.RecordSaved{Controller: controllerID(cc), Model: morph type, ID, Locale, Created, Changes})` after the write inside the transaction (it fires after commit).
3. signpost `content.go`: `ContentTypes`, `ContentTypeForModel`, `Describe` (layering the pattern through `Pattern(key, "")`); in `signpost.go` `add` validate `Content` (Model required, Describe required). Remaining P-10 functions come in Task 4.
4. Journal: `classes/content_descriptors.go` with the post descriptor (`Controller: controllers.PostsControllerID` value "golem15.journal.posts" — use the literal or a const in classes to avoid an import cycle; `Model` = the Post morph name; `Describe` returning per-locale URL built exactly as resolvePost builds alternates, title, plain-text excerpt as Description, featured image URL as Image, body word count as Words, Published, LastMod = updated_at), reading through `cabana.TxFromContext(ctx)` when present; set `Content:` on the journal-post entry in `menu_item_types.go`.
5. Tracer test `TestRecordSavedDescribesNewURL` in `../sm-journal-plugin/record_events_test.go` (Postgres, Journal's admin harness): register a listener for `*pact.RecordSaved`; PUT a post with a new slug through the admin API; the listener receives exactly one event with Controller "golem15.journal.posts", Model equal to the Post morph name, a slug change old → new, and `signpost` `ContentTypeForModel(ev.Model)` + `Describe` return the new URL "/blog/<new-slug>"; a second PUT that the test forces to fail validation fires no event.
6. Docs in the same commits (documentation rule): pact README entries; cabana README "Record events" bullet; signpost README "Content descriptors" usage. Framework commit first (`feat(32-02): record events and content descriptors (tracer)`), then the Journal commit in `../sm-journal-plugin` (`feat: post content descriptor`), both staged by path with `git -c safe.directory='*'` in the sibling repo.
</action>
<verify>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestRecordSavedDescribesNewURL' -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: TestRecordSavedDescribesNewURL" line</fails_when>
<automated>go vet ./... && go test -short ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Content \*ContentDescriptor" modules/pact/capabilities.go` and `grep -n "Changes \[\]RecordChange" modules/pact/capabilities.go` each print one line
- `grep -n "func EmitRecordSaved(ctx context.Context, app \*backpack.App, tx \*gorm.DB, ev pact.RecordSaved)" modules/cabana/record_events.go` prints one line
- `grep -n "lagoon.AfterCommit" modules/cabana/record_events.go` prints at least one line
- `go -C ../sm-journal-plugin test ./... -run TestRecordSavedDescribesNewURL -count=1` passes, including the no-event-on-failed-save assertion
- `grep -rn "sm-seo-plugin\|sm-sitemap-plugin" ../sm-journal-plugin --include=*.go` prints nothing
</acceptance_criteria>
<done>An admin slug change on a Journal post reaches a subscriber after commit and the subscriber reads the new URL through signpost, with no event on a failed save; committed in both repos with docs.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Every emission point, translated-slug changes, the direct-writer helpers and Journal's API emission (D-92, D-102)</name>
<files>modules/cabana/record_events.go, modules/cabana/crud.go, modules/cabana/reorder.go, modules/cabana/relation_child.go, modules/cabana/record_events_postgres_test.go, modules/cabana/README.md, docs/plugins/extending.md, docs/backend/admin-controllers.md, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/record_events_test.go, ../sm-journal-plugin/README.md</files>
<read_first>
- modules/cabana/crud.go (Delete 287, BulkDelete 331, BulkAction 393, save create path, applyMLTranslations call)
- modules/cabana/field_ml.go (liftMLValues 68, applyMLTranslations 163; TranslationWriter in field_ml.go 31-33)
- modules/cabana/reorder.go (reorderPost) and modules/cabana/relation_child.go (child create/update/delete)
- modules/cabana/subject_events_test.go (TestFireSubjectChangedWaitsForCommit pattern)
- docs/plugins/extending.md (Events section, line 11; Model hooks section, line 62)
- ../sm-journal-plugin/controllers/api/posts.go (Store, Update, Destroy, purgeSiteCache call sites, applyPostUpdate)
- ../sm-journal-plugin/journal_api_writes_test.go (API write test harness)
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 2, Pitfalls 8 and 12)
</read_first>
<behavior>
- Create through cabana: one RecordSaved with Created true and the written columns as Changes (Old nil)
- Update changing a translated slug in locale pl only: Changes holds {Attribute "slug", Locale "pl", Old, New}, read with TranslatedExact before applyMLTranslations
- Update that changes nothing: RecordSaved still fires with an empty Changes list (subscribers re-check URLs)
- Delete and each row of a bulk delete: one RecordDeleted per record, after commit
- Tree reorder changing parent_id: RecordSaved with a parent_id change; relation-manager child create/update/delete: events for the child's controller
- Rollback (validation error, extension Save error, FormBefore* error): no event
- A listener returning an error: the save answers 200 and the warning is logged
- Journal PUT /_journal/api/v1/posts/{id} changing the slug: one RecordSaved with Controller "", Model the Post morph name and the slug change; POST: Created true; DELETE: RecordDeleted
</behavior>
<action>
Complete D-92 per the behaviour list. In cabana add the remaining emission points (create path of save, Delete, BulkDelete, reorderPost, relation-manager child writes) through one unexported `emitRecordSaved/emitRecordDeleted` that wraps the exported helpers; per-locale old values come from the TranslationWriter's exact read for submitted ML fields before `applyMLTranslations` (RESEARCH Pitfall 12). Journal per D-102: in `controllers/api/posts.go` capture the old slug (default locale and any submitted locale) before `applyPostUpdate`, then call `cabana.EmitRecordSaved(r.Context(), app, nil, ...)` / `cabana.EmitRecordDeleted` next to `purgeSiteCache` in Store, Update and Destroy (the writes are committed when the helper runs, so a nil tx fires at once).
Tests first: `TestRecordEvents` in `modules/cabana/record_events_postgres_test.go` (create, update, translated slug, no-op update, delete, bulk delete, reorder, child write, rollback cases, failing listener) and `TestAPIRecordEvents` in `../sm-journal-plugin/record_events_test.go` (POST, PUT slug change, DELETE). Docs: `docs/plugins/extending.md` "Record events" subsection under Events (listening with `app.Events.Listen` and the pointer types, after-commit, the emit helpers for direct writers, why GORM hooks on another plugin's model are not the way); `docs/backend/admin-controllers.md` notes which admin operations emit; cabana README API table rows for the two helpers; Journal README notes that its API emits record events.
Framework commit(s) first, then the Journal commit, staged by path.
</action>
<verify>
<automated>go test ./modules/cabana -run 'TestRecordEvents' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated>
<fails_when>a "--- FAIL" or "FAIL" line, "no tests to run", or no "--- PASS: TestRecordEvents" line</fails_when>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestAPIRecordEvents|TestRecordSavedDescribesNewURL' -count=1 2>&1 | tail -5</automated>
<fails_when>non-zero exit or "FAIL" in the output</fails_when>
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check</automated>
<fails_when>non-zero exit, or docs:build reporting an unknown identifier or broken link</fails_when>
</verify>
<acceptance_criteria>
- `go test ./modules/cabana -run TestRecordEvents -v` shows passing subtests for create, update, translated slug, delete, bulk delete, reorder, child write, rollback and failing listener
- `grep -c "cabana.EmitRecord" ../sm-journal-plugin/controllers/api/posts.go` prints 3 or more
- `grep -n "Record events" docs/plugins/extending.md` prints a heading line
- `go vet ./... && go test -short ./...` exits 0 in the framework and `go -C ../sm-journal-plugin vet ./...` exits 0
</acceptance_criteria>
<done>Every cabana write path and Journal's API emit after-commit record events with per-locale old and new values; direct writers have documented helpers.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Complete descriptors in signpost and Journal: categories, the journal-tag type, Records, Match/MatchPath with strict segments, translated slugs (D-93, D-58)</name>
<files>modules/signpost/content.go, modules/signpost/signpost.go, modules/signpost/content_test.go, modules/signpost/README.md, modules/pact/README.md, docs/plugins/menu-item-types.md, ../sm-journal-plugin/classes/content_descriptors.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/content_descriptors_test.go, ../sm-journal-plugin/README.md</files>
<read_first>
- modules/signpost/signpost.go and modules/signpost/content.go (Task 2)
- modules/signpost/signpost_test.go (fixture style)
- docs/plugins/menu-item-types.md (Registering types, Resolving an item, URL patterns)
- ../sm-journal-plugin/classes/menu_item_types.go (categoryReferences 406, resolveCategory 472, postPlaceholders 55)
- ../sm-journal-plugin/controllers/api/posts_helpers.go (findPostBySlugOrID 55-75) and ../sm-journal-plugin/models/post.go (Translatable, TranslatableIndexes)
- ../sm-journal-plugin/models/tag.go and models/tag/fields.yaml; controllers/tags.go (TagsControllerID)
- ../sm-journal-plugin/menu_item_types_test.go
- ../vue-journal-layer/app/utils/routes.ts (tag route default '/blog/tag/:tag')
</read_first>
<behavior>
- signpost: a type without Content is absent from ContentTypes and resolves exactly as before; Content without Model or without Describe stops the boot naming the type and plugin; two types with the same Content.Model or Content.Controller stop the boot
- MatchPattern("/blog/:slug", "/blog/hello") = {slug: hello}, true; MatchPattern("/:category/:slug", "/news/hello") = {category: news, slug: hello}; a path with an extra segment does not match; a pattern placeholder never matches an empty segment
- MatchPath returns the first Exact match in registration order, else the first non-exact one, else no match
- Journal post Match: /blog/<slug> → Exact true; with pattern /:category/:slug, a wrong category for an existing post → Exact false and the post's ID; an unpublished post → no match; /pl/blog/<pl-slug> with pl prefixed → Locale pl, Exact true; the default-locale slug under the pl prefix → Exact false
- journal-tag: References lists tags, Resolve returns /blog/tag/<slug>, Describe and Match work; with golem15.journal.tag_pages false Describe returns empty URLs and Match never matches
- Categories: descriptor with Controller golem15.journal.categories and Describe/Match/Records
- D-58: GET /_journal/api/v1/posts/<translated-slug> with the request locale set resolves the post; the default-locale slug still resolves; an unknown slug still answers 404
</behavior>
<action>
Finish P-10 in signpost (`ContentType`, `ContentTypeForController`, `Records`, `MatchPath`, `MatchPattern`, duplicate Model/Controller checks in `add`/`Activate`) and write `TestContent*` cases in `modules/signpost/content_test.go`. In Journal (D-93, D-96, D-102, D-58): category descriptor; the new `MenuTypeTag = "journal-tag"` const, its label const `golem15.journal::lang.menuitem.journal_tag` with en "Journal tag" and pl "Tag bloga" in the lang files, `DefaultTagPattern = "/blog/tag/:slug"`, tag References/Resolve, and its descriptor with `Controller "golem15.journal.tags"` (P-11, config key `golem15.journal.tag_pages`); `Records` for all three (id order, `After` as the last id, `Limit` capped at 500); `Match` for all three using `signpost.MatchPattern` after stripping a locale prefix for the locales `loadMenuLocales` returns, comparing every placeholder segment (category of the post as resolvePost chooses it, year/month/day, id) and the locale prefix with the record (D-20c); translated slugs matched through the translate plugin's attribute rows for the matched locale. D-58: `findPostBySlugOrID` takes the request locale and, after the default slug, matches the translated slug of that locale (published posts only, as today); update its callers.
Docs: `docs/plugins/menu-item-types.md` gains "Content descriptors" (what each function returns, the tx-from-ctx rule, MatchPattern, Exact semantics, a type with no public pages answering empty URLs) with neutral names; signpost and pact READMEs; Journal README lists the descriptors, journal-tag and tag_pages. Framework commit first, then Journal commits (descriptors + journal-tag; D-58 fix as its own commit `fix: resolve translated post slugs (D-58)`).
</action>
<verify>
<automated>go test -short ./modules/signpost ./modules/pact -run 'TestContent' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestContent" line</fails_when>
<automated>go -C ../sm-journal-plugin test ./... -run 'TestContentDescriptors|TestJournalTagType|TestTranslatedSlug' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>non-zero exit, a "FAIL" line, or any of TestContentDescriptors, TestJournalTagType, TestTranslatedSlug missing from the "--- PASS" lines</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output (the whole Journal suite, including its existing menu and API tests, must stay green)</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func MatchPattern(pattern, path string) (map\[string\]string, bool)" modules/signpost/content.go` prints one line
- `grep -n "\"journal-tag\"" ../sm-journal-plugin/classes/menu_item_types.go` prints one line
- `grep -c "Content: *&pact.ContentDescriptor" ../sm-journal-plugin/classes/menu_item_types.go` prints 3
- `grep -n "journal_tag" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each file
- `grep -n "Content descriptors" docs/plugins/menu-item-types.md` prints a heading line; `go run ./cmd/summer docs:build --check` exits 0
- `go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...` exits 0 with no change in sm-sitemap-plugin (existing consumer unchanged)
</acceptance_criteria>
<done>signpost exposes content types, descriptions, paging and strict path matching; Journal posts, categories and tags take part through descriptors without importing any SEO or sitemap plugin, translated slugs resolve, and existing consumers build unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| public path → descriptor Match | an anonymous visitor's URL segments reach content plugins' queries through MatchPath (used by the public resolver in plan 32-03/04) |
| MCP/API client → Journal write handlers → event subscribers | writes made outside cabana now notify other plugins |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-05 | Denial of service | cabana save with a failing listener | medium | mitigate | Listener errors are logged, never returned; the save answers normally (TestRecordEvents failing-listener case) |
| T-32-06 | Tampering | events for rolled-back writes | high | mitigate | Emission through lagoon.AfterCommit on the save transaction; rollback cases in TestRecordEvents assert no event |
| T-32-07 | Tampering | descriptor Match with visitor-supplied segments | high | mitigate | Placeholder values are bound as query parameters (GORM Where with ?), never concatenated; MatchPattern refuses empty segments and extra segments |
| T-32-08 | Information disclosure | Match/Describe and the D-58 slug lookup on drafts | high | mitigate | Match answers no match for unpublished records and findPostBySlugOrID keeps its published-only scope for anonymous callers; covered by TestContentDescriptors and TestTranslatedSlug |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan |
</threat_model>
<verification>
- Framework: `go vet ./... && go test -short ./...` at every commit; `go test ./modules/cabana -run TestRecordEvents -count=1` (Docker); `go test -short ./modules/signpost -count=1`; docs checker green.
- Journal: `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./...` (Docker) green.
- sm-sitemap-plugin builds and tests unchanged against the framework checkout.
- `grep -rn "sm-seo-plugin" ../sm-journal-plugin --include=*.go` empty.
</verification>
<success_criteria>
A slug change from the admin or the Journal API reaches subscribers after commit with old and new values; Journal posts, categories and tags are described and matched through signpost without importing any SEO plugin; the change is strictly additive.
</success_criteria>
## Artifacts this phase produces (plan 02)
- `pact.RecordChange`, `pact.RecordSaved`, `pact.RecordDeleted` (`modules/pact/record_events.go`)
- `pact.ContentDescriptor`, `pact.ContentList`, `pact.ContentPage`, `pact.ContentDescribe`, `pact.ContentRecord`, `pact.ContentLocale`, `pact.ContentMatch`, `pact.ContentMatchResult` (`modules/pact/content.go`)
- New fields `pact.MenuItemType.Content`, `pact.AdminFormSave.Changes`
- `cabana.EmitRecordSaved`, `cabana.EmitRecordDeleted` (`modules/cabana/record_events.go`)
- `signpost.(*Registry).ContentTypes`, `ContentType`, `ContentTypeForController`, `ContentTypeForModel`, `Describe`, `Records`, `MatchPath`; `signpost.MatchPattern` (`modules/signpost/content.go`)
- Journal: `classes/content_descriptors.go`, `MenuTypeTag` (`journal-tag`), `DefaultTagPattern`, config key `golem15.journal.tag_pages`, phrase `golem15.journal::lang.menuitem.journal_tag`, translated-slug lookup in `findPostBySlugOrID`
- Tests: `TestRecordSavedDescribesNewURL`, `TestRecordEvents`, `TestAPIRecordEvents`, `TestContent*` (signpost), `TestContentDescriptors`, `TestJournalTagType`, `TestTranslatedSlug`
- Docs: "Record events" in `docs/plugins/extending.md`, "Content descriptors" in `docs/plugins/menu-item-types.md`
<output>
Create `.planning/phases/32-seo-foundation/32-02-SUMMARY.md` when done, with the Task 1 answer and every commit SHA in both repositories (the Journal commits are needed by plan 32-05's demo submodule move and plan 32-06's release).
</output>

View File

@@ -0,0 +1,294 @@
---
phase: 32-seo-foundation
plan: 02
subsystem: framework-seams
tags: [pact, cabana, signpost, record-events, content-descriptors, d-92, d-93, journal]
requires:
- phase: 32-01
provides: pact.AdminFormSave and the form extension Save in the record's save transaction (D-91)
provides:
- "pact.RecordChange, pact.RecordSaved, pact.RecordDeleted fired as pointers on app.Events after commit (D-92)"
- "pact.AdminFormSave.Changes: an extension's Save sees the owner's changes inside the save transaction"
- "cabana.EmitRecordSaved / cabana.EmitRecordDeleted for direct writers; cabana emits from create, update, delete, bulk delete, tree reorder and relation-manager child writes"
- "pact.ContentDescriptor (+ ContentList, ContentPage, ContentDescribe, ContentRecord, ContentLocale, ContentMatch, ContentMatchResult) and pact.MenuItemType.Content (D-93)"
- "signpost: ContentTypes, ContentType, ContentTypeForController, ContentTypeForModel, Describe, Records, MatchPath and the pure MatchPattern; duplicate Model/Controller and missing Model/Describe stop the boot"
- "Journal (sm-journal-plugin): descriptors for journal-post, journal-category and the new journal-tag type, record events from /_journal/api/v1 Store/Update/Destroy (D-102), translated-slug lookup (D-58), host key golem15.journal.tag_pages"
affects: [32-03, 32-04, 32-05, 32-06, sm-seo-plugin, sm-sitemap-plugin]
actuals:
tokens: 35953
tasks: 4
commits: 3
plan_head_before: 1b0421de814404d4bf904a754bf997badfd4349c
plan_head_after: 785902c612228bdf1bc0392fff05a1cbbd84a59f
tech-stack:
added: []
patterns:
- "Record events: snapshot the row (GORM schema columns minus PK and auto timestamps) plus TranslatedExact of submitted ML fields before the write, diff after, emit through lagoon.AfterCommit"
- "Content descriptors read through cabana.TxFromContext when a write transaction is in ctx"
- "Strict URL matching: read the locale prefix, MatchPattern the rest, find the record, compare the path with the record's own alternate URL (exact) else inexact"
key-files:
created:
- modules/pact/record_events.go
- modules/pact/content.go
- modules/cabana/record_events.go
- modules/cabana/record_events_postgres_test.go
- modules/cabana/example_record_events_test.go
- modules/signpost/content.go
- modules/signpost/content_test.go
- modules/signpost/example_content_test.go
- ../sm-journal-plugin/classes/content_descriptors.go
- ../sm-journal-plugin/controllers/api/record_events.go
- ../sm-journal-plugin/record_events_test.go
- ../sm-journal-plugin/content_descriptors_test.go
- ../sm-journal-plugin/translated_slug_test.go
modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/crud.go
- modules/cabana/form_extensions.go
- modules/cabana/http.go
- modules/cabana/relation.go
- modules/cabana/relation_child.go
- modules/cabana/reorder.go
- modules/cabana/README.md
- modules/signpost/signpost.go
- modules/signpost/README.md
- docs/plugins/extending.md
- docs/plugins/menu-item-types.md
- docs/backend/admin-controllers.md
- ../sm-journal-plugin/classes/menu_item_types.go
- ../sm-journal-plugin/classes/menu_item_types_test.go
- ../sm-journal-plugin/controllers/api/posts.go
- ../sm-journal-plugin/controllers/api/posts_helpers.go
- ../sm-journal-plugin/lang/en/lang.yaml
- ../sm-journal-plugin/lang/pl/lang.yaml
- ../sm-journal-plugin/README.md
key-decisions:
- "Task 1 (D-92/D-93 Go shapes), answered by Jakub before dispatch: as-planned. P-07 to P-10 are published as written, including the optional Match and signpost.MatchPath"
- "RecordChange.Locale is \"\" for the record's own row (the default locale of a translatable field) and the locale code for a translated value; values are dereferenced (nil pointer -> nil) and times compare as instants"
- "Changes leave out the primary key and GORM auto timestamps, so a no-op update fires RecordSaved with an empty Changes list; on create they list every column with a non-zero value and every non-empty submitted translation, Old nil"
- "Reorder fires one RecordSaved per moved node only (parent and/or sort column changed), values converted to the model's column types"
- "Relation-manager child events name the one admin controller whose NewRecord type is the child's model (\"\" when none or several); API and other direct writes carry Controller \"\""
- "signpost.Registry.Records answers an empty page for a content type without Records; MatchPath returns the first exact match in registration order, else the first inexact one; a Match error is returned prefixed signpost:"
- "signpost.MatchPattern matches whole segments only; a placeholder never matches an empty segment; a placeholder used twice must match the same value; segments are compared as given (caller decodes)"
- "Journal descriptors describe drafts too (Published false); Records page in id order capped at 500; Match never matches drafts/future posts and is exact only on the record's own URL in the path's locale"
- "journal-tag with golem15.journal.tag_pages false: Describe answers empty URLs, Match never matches and Resolve resolves to nothing"
- "D-58: findPostBySlugOrID(db, slug, locale) tries the default slug, then translate's slug index for the request locale (towel, first language without region), then the id"
patterns-established:
- "cabana.EmitRecordSaved(ctx, app, tx, ev): tx = the write's lagoon.Transaction handle, or nil after commit (fires at once)"
- "A content type's Match: signpost.MatchPattern on the path without its locale prefix, find by slug-in-locale/default slug/other-locale slug/id, exact iff path == own alternate URL"
requirements-completed: [SC-2, SC-3, D-92, D-93, D-96, D-102, D-58, D-90, D-18, D-20c, D-11]
coverage:
- id: D1
description: "Tracer: an admin slug change on a Journal post fires one RecordSaved after commit (Controller, Model = Post morph, default and pl slug changes); signpost ContentTypeForModel + Describe return /blog/<new-slug>; a failed save fires nothing"
requirement: "SC-3"
verification:
- kind: integration
ref: "../sm-journal-plugin/record_events_test.go#TestRecordSavedDescribesNewURL"
status: pass
human_judgment: false
- id: D2
description: "Every cabana write path emits after commit: create (all columns + translations, Old nil), update, translated slug per locale, no-op (empty Changes), rollback on validation/FormBeforeUpdate/extension Save errors fires nothing, failing listener never fails the save, reorder, child create/update/delete, delete, bulk delete (partial selection fires nothing); extension Save receives the same Changes"
requirement: "D-92"
verification:
- kind: integration
ref: "modules/cabana/record_events_postgres_test.go#TestRecordEvents"
status: pass
- kind: unit
ref: "modules/cabana/example_record_events_test.go#ExampleEmitRecordSaved"
status: pass
human_judgment: false
- id: D3
description: "Journal /_journal/api/v1 POST/PUT/DELETE fire RecordSaved (Created, slug changes per locale) and RecordDeleted with Controller \"\""
requirement: "D-102"
verification:
- kind: integration
ref: "../sm-journal-plugin/record_events_test.go#TestAPIRecordEvents"
status: pass
human_judgment: false
- id: D4
description: "signpost content lookups, Records, boot errors (no Model, no Describe, duplicate Model/Controller), MatchPattern rules and MatchPath precedence; a type without Content resolves as before"
requirement: "D-93"
verification:
- kind: unit
ref: "modules/signpost/content_test.go#TestContentLookups"
status: pass
- kind: unit
ref: "modules/signpost/content_test.go#TestContentBootErrors"
status: pass
- kind: unit
ref: "modules/signpost/content_test.go#TestContentMatchPattern"
status: pass
- kind: unit
ref: "modules/signpost/content_test.go#TestContentMatchPath"
status: pass
- kind: unit
ref: "modules/signpost/example_content_test.go#ExampleRegistry_MatchPath"
status: pass
human_judgment: false
- id: D5
description: "Journal post/category/tag descriptors: Describe per locale, Records paging, strict Match (wrong category, stale or wrong locale prefix, other-locale slug inexact; drafts never match); journal-tag type with tag_pages switch"
requirement: "D-20c"
verification:
- kind: integration
ref: "../sm-journal-plugin/content_descriptors_test.go#TestContentDescriptors"
status: pass
- kind: integration
ref: "../sm-journal-plugin/content_descriptors_test.go#TestJournalTagType"
status: pass
human_judgment: false
- id: D6
description: "D-58: GET /_journal/api/v1/posts/<translated-slug> with Accept-Language pl resolves the post; default slug and id still resolve; unknown slug 404"
requirement: "D-58"
verification:
- kind: integration
ref: "../sm-journal-plugin/translated_slug_test.go#TestTranslatedSlug"
status: pass
human_judgment: false
- id: D7
description: "Strictly additive (D-106): framework vet + go test -short green, full cabana/signpost/pact/lagoon Docker suites green, whole Journal suite green, sm-sitemap-plugin builds and tests unchanged, sm-demo-app builds unchanged; Journal imports neither sm-seo-plugin nor sm-sitemap-plugin"
requirement: "SC-2"
verification:
- kind: other
ref: "go vet ./... && go test -short ./... (summercms.go); go test ./modules/cabana ./modules/signpost ./modules/pact ./modules/lagoon/..."
status: pass
- kind: other
ref: "go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./..."
status: pass
- kind: other
ref: "go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...; go -C ../sm-demo-app build ./..."
status: pass
human_judgment: false
duration: 37min
completed: 2026-10-26
status: complete
---
# Phase 32 Plan 02: Record events and content descriptors Summary
**D-92 record events (pact.RecordSaved/RecordDeleted with per-locale old/new changes, fired after commit from every cabana write path and the Journal API) and D-93 content descriptors (pact.ContentDescriptor in signpost with Describe, Records and strict MatchPath), adopted by Journal for posts, categories and a new journal-tag type, plus the D-58 translated-slug fix**
## Task 1 answer
Task 1 (checkpoint:decision on the D-92/D-93 Go shapes) was answered by Jakub before dispatch: **as-planned**. P-07 to P-10 are adopted as written, including the optional `Match` and `signpost.Registry.MatchPath`. The executor did not stop at Task 1.
## Performance
- **Duration:** 37 min
- **Started:** 2026-10-26T21:11:36Z
- **Completed:** 2026-10-26T21:49:02Z
- **Tasks:** 4 (1 decision resolved before dispatch, 3 executed)
- **Files modified:** 22 in summercms.go, 12 in sm-journal-plugin
## Accomplishments
- pact: `RecordChange`, `RecordSaved`, `RecordDeleted`, `AdminFormSave.Changes`, `MenuItemType.Content` and the eight `Content*` types; every change is a new type or a new last field.
- cabana: `EmitRecordSaved`/`EmitRecordDeleted` (lagoon.AfterCommit, listener errors logged); the admin save snapshots the row and the submitted translations before the write and fires `RecordSaved` with the diff; delete, bulk delete, tree reorder and relation-manager child writes fire too; the same changes reach a form extension's `Save`.
- signpost: `ContentTypes`, `ContentType`, `ContentTypeForController`, `ContentTypeForModel`, `Describe`, `Records`, `MatchPath`, `MatchPattern`; boot checks for the descriptor.
- Journal: post, category and tag descriptors (Describe per locale with URL/title/description/image/words, Records, strict Match), the `journal-tag` type (`/blog/tag/:slug`, `golem15.journal.tag_pages`), record events from the API writes, translated-slug lookup in `GET /posts/{slug}`.
- Docs: "Record events" in docs/plugins/extending.md, "Content descriptors" in docs/plugins/menu-item-types.md, admin-controllers notes, pact/cabana/signpost and Journal READMEs; src= snippets from new example tests.
## Task Commits
summercms.go (master):
1. **Task 2: record events and content descriptors (tracer)** - `753fb59` (feat)
2. **Task 3: record events from every admin write path** - `ff62906` (feat)
3. **Task 4: signpost content lookups, Records and MatchPath** - `785902c` (feat)
../sm-journal-plugin (main):
1. **Task 2: post content descriptor + tracer test** - `08ab153` (feat)
2. **Task 3: record events from the Journal API writes (D-102)** - `4b3a8bb` (feat)
3. **Task 4: content descriptors for posts, categories and journal-tag** - `e17cebd` (feat)
4. **Task 4: resolve translated post slugs (D-58)** - `a598a7c` (fix)
5. **Task 4: declare the content descriptors in MenuItemTypes** - `c5b7147` (refactor)
**Plan metadata:** recorded in the docs(32-02) commit that adds this file.
## TDD Gate Compliance
Tasks 3 and 4 were written tests-first. RED was verified for each with `msd_run check tdd-red-evidence` (verdict `RED_EVIDENCE_OK`, go test -json converted to TAP):
- Task 3 framework: TestRecordEvents failed on the reorder, child write, delete and bulk delete subtests ("saved [] deleted []").
- Task 3 Journal: TestAPIRecordEvents failed ("POST events = [], want one RecordSaved").
- Task 4 signpost: TestContent* failed on assertions after compile stubs (ContentType false, Activate accepted the descriptor, MatchPattern false, MatchPath no match).
- Task 4 Journal: TestContentDescriptors, TestJournalTagType and TestTranslatedSlug failed (ContentTypes = journal-post, journal-tag unregistered, 404).
The RED tests were not committed on their own: project CLAUDE.md requires `go test ./...` green at every commit, so each test landed in the same `feat` commit as the code that makes it pass (no separate `test(32-02)` commits). This is a CLAUDE.md-driven deviation from the tdd.md commit pattern.
## Decisions Made
See key-decisions in the frontmatter. In short: event and descriptor shapes exactly as P-07 to P-10; `Locale ""` means the record's own row; no-op saves still fire; reorder fires per moved node; child events name the controller of the child's model; Records without a func is an empty page; MatchPath takes the first exact, else the first inexact; Journal describes drafts but never matches them.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] CRUDService and RelationService had no handle on the application**
- **Found during:** Task 2
- **Issue:** The emission needs `app.Events`; the services carried only DB, bucket, translator and writer.
- **Fix:** unexported `app` field on CRUDService and `app`/`reg` on RelationService, set by `service.crud()`/`service.relations()`. A service built without them (other packages, old tests) fires nothing.
- **Files modified:** modules/cabana/crud.go, relation.go, http.go
- **Committed in:** 753fb59, ff62906
**2. [Rule 3 - Blocking] docs checker refuses go code blocks without src=**
- **Found during:** Task 3 and Task 4
- **Fix:** examples `ExampleEmitRecordSaved` (cabana) and `NewsPlugin` + `ExampleRegistry_MatchPath` (signpost) back the docs snippets, refreshed with `summer docs:sync`.
- **Committed in:** ff62906, 785902c
**3. [Rule 1 - Acceptance criterion] posts.go needed the emit calls inline**
- **Found during:** Task 3: the criterion counts `cabana.EmitRecord` in posts.go (>= 3); the helper builds the event (`postSavedEvent`) and the handlers call `cabana.EmitRecordSaved`/`EmitRecordDeleted` directly.
- **Committed in:** 4b3a8bb
**4. [Rule 1 - Acceptance criterion] content descriptors declared inline**
- **Found during:** Task 4 verification: the criterion counts `Content: &pact.ContentDescriptor` in menu_item_types.go (3); the descriptors moved from helper funcs to literals (refactor commit, no behaviour change).
- **Committed in:** c5b7147
**5. [CLAUDE.md] No separate RED commits** (see TDD Gate Compliance).
**6. [Rule 2 - Scope] journal-tag Resolve answers nothing without tag pages**
- The plan names Describe and Match for `tag_pages: false`; Resolve follows the same rule so a menu or sitemap never links a tag page the host does not have.
**7. Test fixture adjustments in Journal**
- `TestMenuItemTypesPatterns` lists six types (journal-tag appended); D-58's test lives in its own file `translated_slug_test.go` so the descriptor commit and the fix commit are each green.
---
**Total deviations:** 7 (2 blocking, 2 acceptance-criterion shape, 1 CLAUDE.md commit rule, 1 scope consistency, 1 test fixture)
**Impact on plan:** No scope creep; the published shapes are P-07 to P-10 as answered.
## Issues Encountered
- The translate migrations seed `en` and Journal seeds an "Uncategorized" category; the descriptor test seeds locales with `WHERE NOT EXISTS` and checks the category page by its last record.
## Threat Flags
None beyond the plan's threat model: T-32-05 (failing listener logged, save 200), T-32-06 (AfterCommit, rollback cases), T-32-07 (path values only as GORM `?` parameters; MatchPattern refuses empty and extra segments) and T-32-08 (drafts never match; findPostBySlugOrID keeps its scope, Show still hides drafts from anonymous callers) are covered by TestRecordEvents, TestContentMatchPattern, TestContentDescriptors and TestTranslatedSlug.
## User Setup Required
None. A host without tag pages sets `golem15.journal.tag_pages: false` (or `SUMMER_GOLEM15__JOURNAL__TAG_PAGES=false`).
## Next Phase Readiness
- sm-seo-plugin (32-03) can listen for `*pact.RecordSaved`/`*pact.RecordDeleted`, map `Model` to a content type with `ContentTypeForModel`, read URLs with `Describe` and resolve paths with `MatchPath`.
- Plan 32-05 moves the demo app's Journal submodule to `c5b7147` (or later); the demo app builds unchanged against the framework checkout today.
- Nothing pushed or tagged.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-26*
## Self-Check: PASSED
All created files exist; framework commits 753fb59, ff62906, 785902c and Journal commits 08ab153, 4b3a8bb, e17cebd, a598a7c, c5b7147 are present.

View File

@@ -0,0 +1,462 @@
---
phase: 32-seo-foundation
plan: 03
type: execute
wave: 3
depends_on: ["32-01", "32-02"]
files_modified:
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- ../sm-seo-plugin/go.work
- ../sm-seo-plugin/.gitignore
- ../sm-seo-plugin/README.md
- ../sm-seo-plugin/plugin.go
- ../sm-seo-plugin/logger.go
- ../sm-seo-plugin/admin.go
- ../sm-seo-plugin/admin_permissions.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/field_types.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/jobs.go
- ../sm-seo-plugin/commands.go
- ../sm-seo-plugin/listeners.go
- ../sm-seo-plugin/config/config.yaml
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.yaml
- ../sm-seo-plugin/models/registry.go
- ../sm-seo-plugin/models/entry.go
- ../sm-seo-plugin/models/revision.go
- ../sm-seo-plugin/models/settings.go
- ../sm-seo-plugin/models/type_setting.go
- ../sm-seo-plugin/models/rule.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/models/rule/fields.yaml
- ../sm-seo-plugin/models/rule/columns.yaml
- ../sm-seo-plugin/models/revision/fields.yaml
- ../sm-seo-plugin/models/revision/columns.yaml
- ../sm-seo-plugin/updates/registry.go
- ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go
- ../sm-seo-plugin/updates/202610270002_create_golem15_seo_revisions.go
- ../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go
- ../sm-seo-plugin/updates/202610270004_create_golem15_seo_type_settings.go
- ../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/settings.go
- ../sm-seo-plugin/controllers/settings/config_form.yaml
- ../sm-seo-plugin/controllers/settings/_staging.htm
- ../sm-seo-plugin/controllers/settings/_placeholders.htm
- ../sm-seo-plugin/controllers/rules.go
- ../sm-seo-plugin/controllers/rules/config_list.yaml
- ../sm-seo-plugin/controllers/rules/config_form.yaml
- ../sm-seo-plugin/controllers/rules/_seo_status.htm
- ../sm-seo-plugin/controllers/rules/_seo_effective.htm
- ../sm-seo-plugin/controllers/history.go
- ../sm-seo-plugin/controllers/history/config_list.yaml
- ../sm-seo-plugin/controllers/history/config_form.yaml
- ../sm-seo-plugin/controllers/history/_history_diff.htm
- ../sm-seo-plugin/controllers/api/resolve.go
- ../sm-seo-plugin/extension/_seo_status.htm
- ../sm-seo-plugin/extension/_seo_effective.htm
- ../sm-seo-plugin/extension/_placeholders.htm
- ../sm-seo-plugin/classes/service.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/templates.go
- ../sm-seo-plugin/classes/urls.go
- ../sm-seo-plugin/classes/robots_value.go
- ../sm-seo-plugin/classes/entries.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/classes/snippet.go
- ../sm-seo-plugin/classes/image.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/classes/generate.go
- ../sm-seo-plugin/package.json
- ../sm-seo-plugin/package-lock.json
- ../sm-seo-plugin/tsconfig.json
- ../sm-seo-plugin/vite.config.ts
- ../sm-seo-plugin/scripts/check-dist.sh
- ../sm-seo-plugin/assets/src/elements/index.ts
- ../sm-seo-plugin/assets/src/elements/measure.ts
- ../sm-seo-plugin/assets/src/elements/labels.ts
- ../sm-seo-plugin/assets/src/elements/text.ts
- ../sm-seo-plugin/assets/src/elements/robots.ts
- ../sm-seo-plugin/assets/src/elements/serp.ts
- ../sm-seo-plugin/assets/src/elements/image.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/testmain_test.go
- ../sm-seo-plugin/fixture_test.go
- ../sm-seo-plugin/seo_tab_test.go
- ../sm-seo-plugin/settings_test.go
autonomous: false
requirements: [SC-2, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-15, D-21, D-22, D-23, D-90, D-91, D-92, D-93, D-94, D-95, D-100, D-104, D-13]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 5
confidence: low
must_haves:
truths:
- "A registered content type's admin form shows the SEO tab added by sm-seo-plugin through the D-91 extension, and its values are stored in the plugin's one table keyed by model type, record id and locale; the content plugin imports nothing from sm-seo-plugin (D-01, D-93, SC-2)"
- "GET /_seo/api/v1/resolve?url=... answers the effective SEO of a URL for published content: title, description, canonical, robots, the X-Robots-Tag value, Open Graph and Twitter values, and verification meta on the home page (D-02, D-04, D-05, D-06, D-08, SC-2)"
- "Precedence per value is URL rule > record > per-type template or global settings > content-derived values, and the resolver records the source of every value (rule, record, template, global, content, default) (D-02, D-13)"
- "A URL rule (exact or pattern such as /blog?tag=*) sets SEO for an address with or without a record behind it and wins over the record's tab (D-02)"
- "Global SEO settings hold the title and home templates, default description, default social image, site name, twitter:site, verification codes and files, the query-parameter list, never-indexed and search paths, and the address options; per content type a tab holds the inject switch, title and description templates and the default robots (D-03, D-05, D-07, D-21, D-104)"
- "Canonical is self-referencing and absolute from the configured site URL by default, keeps content parameters, drops filter and tracking parameters, and takes record or rule overrides, relative or absolute (D-06, D-07)"
- "Robots resolve per directive through rule > record > never-indexed/search paths and filter-parameter URLs > type default > index,follow; the same directives are returned as the X-Robots-Tag value; outside production every answer is noindex,nofollow with staging true (D-08, D-21, D-22, D-23)"
- "One permission golem15.seo.manage covers the SEO tab, URL rules, settings and history; without it there is no navigation item, no settings tile and no SEO tab, and saves leave SEO values untouched (D-15)"
- "Every SEO write from the admin is recorded in history with author, time, via and before/after values (D-13)"
- "Deleting a record deletes its SEO entries after commit while history keeps the values (D-92, D-94)"
- "A GenerateEntries job run through conga creates missing entries with the current URL of every published record (D-94)"
- "The SEO tab's anchor names seo_robots and seo_og_image are reserved for Phase 33 fields and the entry has a data jsonb column (D-43/D-44 room)"
- "SEO tab on a new record: P1 and P2 do not render, E3 shows the address-after-first-save line and the template title, E1 shows the empty status (UI-SPEC empty)"
- "E3 without a description anywhere shows the no-description line in muted italic (UI-SPEC empty)"
- "E3 shows a skeleton card until the context action answers and a checking line while the duplicate action is pending (UI-SPEC loading)"
- "Lists, forms and previews show the existing skeletons, and plugin elements show the existing PluginField/WidgetField skeleton until they are defined (UI-SPEC loading)"
- "When an element script fails or times out the existing ExtensionFailure box shows; a failed widget action shows the danger toast and E3 still renders from fill-values alone (UI-SPEC error)"
- "A refused save shows field lines, the count banner and the SEO tab error badge (UI-SPEC error)"
- "SEO tab fields follow UI-SPEC C3 order and spans, with every ML value under the form's one locale switcher (UI-SPEC populated)"
- "A record with SEO in one locale only: the other locale's values are empty (inherit), E1 shows empty, E2 shows Inherit, P2 shows template or content sources (UI-SPEC partial)"
- "A locale without an address shows effective.no_url in P2 and noUrl in E3 (UI-SPEC partial)"
- "A URL rule overriding a record shows the status.rule callout at the top of the SEO tab listing the overridden properties (UI-SPEC partial)"
- "A type whose Show the SEO tab switch is off has the tab removed per request while stored values keep working (UI-SPEC partial)"
- "Outside production the settings and robots screens show the staging callout and P2 shows the status.staging note (UI-SPEC partial)"
- "E3 duplicate warnings: none for zero, plural dupTitle/dupDescription for one or many, at most three links plus andMore (UI-SPEC zero-one-many)"
- "Settings show General, one tab per registered content type in registration order, then Indexing, Addresses and Verification; zero registered types leaves only the four fixed tabs (UI-SPEC zero-one-many, C11)"
- "Settings repeaters use titleFrom and collapse per row; long URLs in P2 wrap with overflow-wrap anywhere; E3 truncates title and description at the pixel budget with an ellipsis (UI-SPEC overflow, long-text)"
- "URL rules list shows the empty-state copy rules.list_empty, url/match/overrides/updated columns, and the form follows UI-SPEC C7 (UI-SPEC empty, populated)"
- "History list shows history.list_empty when empty and newest first with Undone badges when populated (UI-SPEC empty, populated)"
- "Keyphrase checks match Polish inflection by the C6 stem rule: grzyby matches grzybów, case and diacritics ignored (UI-SPEC partial, Q-4)"
- "Every OG image field (SEO tab, URL rule, settings default) is an seoimage field that uploads its own file; extensions own no attachments (D-100)"
prohibitions:
- statement: "Content plugins never import sm-seo-plugin, and the SEO data never lives in content plugins' tables or columns (D-90, D-01)"
status: resolved
verification: test
- statement: "The public resolver never answers the SEO of an unpublished record and never builds absolute URLs from the request Host header"
status: resolved
verification: test
- statement: "The non-production lock cannot be disabled from the admin or MCP; it depends on the environment only (D-22)"
status: resolved
verification: test
artifacts:
- path: "../sm-seo-plugin/plugin.go"
provides: "Plugin golem15.seo: Register/Boot, interface assertions"
contains: "func (p *Plugin) ID() string { return \"golem15.seo\" }"
- path: "../sm-seo-plugin/extensions.go"
provides: "AdminActionExtensions: the SEO tab per content type and the per-type tabs on golem15.seo.settings"
contains: "pact.AdminFormExtension"
- path: "../sm-seo-plugin/classes/resolver.go"
provides: "Resolver.Resolve with per-value sources and precedence"
contains: "func (r *Resolver) Resolve("
- path: "../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go"
provides: "golem15_seo_entries keyed by (model_type, record_id, locale) with data jsonb"
contains: "golem15_seo_entries"
- path: "../sm-seo-plugin/assets/dist/seo.js"
provides: "E1, E2, E3, E7 custom elements"
- path: "../sm-seo-plugin/seo_tab_test.go"
provides: "TestSEOTabTracer, TestSEOTab"
contains: "func TestSEOTabTracer("
key_links:
- from: "../sm-seo-plugin/extensions.go"
to: "summercms.go/modules/signpost/content.go"
via: "AdminActionExtensions reads signpost ContentTypes() and targets each Content.Controller"
pattern: "ContentTypes\\(\\)"
- from: "../sm-seo-plugin/controllers/api/resolve.go"
to: "../sm-seo-plugin/classes/resolver.go"
via: "GET /_seo/api/v1/resolve calls Resolver.Resolve"
pattern: "Resolve\\(r\\.Context\\(\\)"
- from: "../sm-seo-plugin/listeners.go"
to: "summercms.go/modules/pact/record_events.go"
via: "app.Events.Listen(\"golem15.seo\", func(ctx, *pact.RecordDeleted) error)"
pattern: "\\*pact\\.RecordDeleted"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan delivers the editor-facing core: the SEO tab on every registered content type, URL rules, global settings with per-type tabs, history, and the effective-SEO answer for the frontend.
<objective>
Create `sm-seo-plugin` (Golem15.SEO, module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, ID `golem15.seo`) at `../sm-seo-plugin` per D-94, on the seams of plans 32-01 and 32-02 only:
- models and migrations: entries (D-01), revisions (history core, D-13), settings, per-type settings, URL rules (D-02);
- the SEO tab added to every content type with a `Content.Controller` through D-91 (D-01, D-04, D-06, D-08, D-09), with the `seoimage` field type for the social image (D-100);
- the settings screen as an `AdminSingleton` linked from a settings tile, its per-type tabs added through D-91 on itself (D-104, D-03, D-05, D-07, D-21);
- the effective-SEO resolver with per-value sources and its public `GET /_seo/api/v1/resolve` (D-02, D-06, D-07, D-08, D-21, D-22 lock, D-23 path patterns);
- the GenerateEntries job (D-94), entry cleanup on `RecordDeleted` (D-92), one permission (D-15);
- UI-SPEC elements E1, E2, E3, E7 and partials P1, P2, P3, P5.
Redirects, normalisation, strict URLs and robots.txt are plan 32-04; the management API, MCP, batch, undo and CSV are plan 32-05. `Requires()` stays empty in Phase 32: the D-95 sitemap filter interface and the `Requires` on Golem15.Sitemap arrive with the sitemap rewrite in Phase 33.
Purpose: SC-2 (Journal posts, categories and tags show the SEO tab without importing sm-seo-plugin; the frontend gets the effective SEO of any URL with the precedence URL rule > record > global > derived).
Output: the new repository with commits on branch `main`, nothing pushed (pushing waits for Jakub's go-ahead in plan 32-06). Which plan implements each decision of the phase, and what Phase 33 takes, is in `32-DECISIONS-MAP.md`.
**Planner decisions recorded here (Claude's discretion: table and package names, CONTEXT):**
- **P-12 (repository):** clone `git@git.golem15.com:golem15/sm-seo-plugin.git` (it exists, empty, public: created on 2026-10-26 with `tea`, D-106) into `../sm-seo-plugin` and work on branch `main`; if cloning an empty repository is refused, `git init -b main` and `git remote add origin git@git.golem15.com:golem15/sm-seo-plugin.git`. No push in this plan. `go.mod` requires the framework at the latest existing tag from `git -C ../summercms.go tag --sort=-v:refname | head -1` (no version written into the plan); `go.work` (tracked, like the sibling plugins) uses `.` and replaces `git.golem15.com/golem15/summercms => ../summercms.go`.
- **P-13 (tables):** prefix `golem15_seo_`; `entries` (id, model_type, record_id text, locale, type_key, url, title, description, keyphrase, canonical, robots, og_title, og_description, og_image, effective_title, effective_description, data jsonb not null default '{}', created_at, updated_at; unique (model_type, record_id, locale)); `revisions` (id, batch_id text null, author_id null, author_label, via admin|mcp|import|auto|undo, subject_kind entry|rule|redirect|robots|settings|notfound, subject_id text, what, before jsonb, after jsonb, undone_at null, undone_by_id null, created_at); `settings` (singleton row 1; site_name, twitter_site, title_template default '%title% | %site%', home_template default '%site%', default_description, default_og_image, query_params jsonb seeded with page=content, utm_*/fbclid/gclid/msclkid=tracking, sort=filter; noindex_paths, search_paths, url_host keep|www|bare, url_slash keep|add|remove, url_lowercase bool default false, verify_google, verify_bing, verify_yandex, verify_other jsonb, verify_files jsonb, csv_delimiter, robots columns added in plan 32-04); `type_settings` (type_key primary key, inject bool default true, title_template, description_template, robots); `rules` (id, url, match exact|pattern, title, description, canonical, robots, og_title, og_description, og_image, note, timestamps; unique (url, match)). Raw SQL DDL, timestamped migration IDs, one file per table (sm-sitemap-plugin pattern).
- **P-14 (precedence per value, D-02 with the approved UI-SPEC copy):** title: rule > record > type title template, else the general template, applied to the content title (source template) > content title > site name; description: rule > record > type description template > content description (excerpt) > settings default description (source global); og:title, og:description: rule > record > effective title/description; og:image: rule > record > content image > settings default social image; canonical: rule > record > self; robots per directive: rule > record > never-indexed paths (noindex,nofollow), search paths and URLs carrying a filter parameter (noindex,follow) > type default > index,follow. The per-type template is the "global defaults" layer of D-02; the site-wide default description and default social image are last-resort fallbacks after content, as the approved copy `settings.default_description_comment` states.
- **P-15 (resolve answer):** `GET /_seo/api/v1/resolve?url=<path or absolute URL>` (throttle bucket `seo-resolve`, no auth) answers `{"action":"ok"|"not_found","status":200|404,"url":<absolute canonical page URL>,"locale":...,"staging":bool,"seo":{"title","description","canonical","robots","x_robots_tag","og":{"title","description","image","url"},"twitter":{"card","site"},"verification":[{"name","content"}]}}`; plan 32-04 adds `redirect` and `gone`. Absolute URLs use the config key `golem15.seo.site_url` (fallback `app.url`), never the request Host. Unpublished records and unknown paths without a rule answer `not_found`.
- **P-16 (OG image, D-100):** the `seoimage` field type (element `golem15-seo-image`, E7) stores an uploaded image in the media bucket (`*blob.Bucket`, folder `seo`, content sniffing as in Journal's editor upload) and keeps its URL; it is used for the SEO tab's `seo_og_image`, the rule form's `og_image` and the settings' `default_og_image` (one storage form for the resolver; UI-SPEC C7/C11 fileupload replaced by D-100). One image per record, shared by every locale row (Q-5).
- **P-17 (Journal Post tab order):** the SEO tab is placed with no anchor, at the end; Journal Post renders Edit | Manage | Categories | SEO (plan 32-01 P-02), which updates UI-SPEC C3's example.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-RESEARCH.md
@.planning/phases/32-seo-foundation/32-PATTERNS.md
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
@CLAUDE.md
<interfaces>
From plan 32-01 (pact): AdminActionExtension{Controller, RecordActions, Form *AdminFormExtension}; AdminFormExtension{Tabs []AdminFormTab; Fields []AdminFormField; Hide, Permissions []string; Visible func(ctx) bool; Dir string; Actions []AdminAction; PartialData; Load func(ctx, AdminFormLoad) (map[string]any, error); Save func(ctx, AdminFormSave) (AdminFormSaveResult, error)}; AdminFormField{Name, Tab, Before, After, Zone string; Config map[string]any}; AdminFormErrors; AdminFormSaveResult{Notices map[string][]FormFieldNotice}. (If the plan 32-01 Task 1 answer changed the shape, use the shape recorded in 32-01-SUMMARY.md.)
From plan 32-02 (pact/signpost/cabana): RecordSaved/RecordDeleted (pointer events); ContentDescriptor{Controller, Model, Records, Describe, Match}; ContentRecord{ID, Published, LastMod, Locales []ContentLocale{Locale, URL, Title, Description, Image, Words}}; signpost Registry ContentTypes(), ContentTypeForController(id), ContentTypeForModel(model), Describe(ctx, key, id, unprefixed), Records(ctx, key, after, limit, unprefixed), MatchPath(ctx, path, unprefixed), MatchPattern(pattern, path); cabana.TxFromContext(ctx); AdminFormSave.Changes.
Analogs (PATTERNS.md): ../sm-sitemap-plugin/plugin.go (plugin entry, interface assertions, boot error prefix), admin.go (embeds, AdminControllers lazy app accessor, Settings link entry with Controller), admin_permissions.go, controllers/definitions.go (AdminSingleton SingletonRecord first-or-create), models/settings.go + models/settings/fields.yaml, updates/registry.go + 202610110002_create_golem15_sitemap_settings.go (raw SQL migrations), testmain_test.go (flag.Parse + testing.Short, postgres:16-alpine); ../sm-journal-plugin/field_types.go + classes/blog_editor.go 80-109 and 343-380 (field-type pair, Upload into *blob.Bucket); ../sm-journal-plugin/routes.go (throttled groups); ../sm-journal-plugin/controllers/api/auth.go 83-115 (backend principal); ../sm-chat-plugin/plugin.go 85-100 (conga.Job); ../sm-journal-plugin/package.json, vite.config.ts, scripts/check-dist.sh, assets/src/blogeditor/labels.ts (element bundle).
Framework: cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, kind, slug); compass Config.Environment(); bouncer.Dispatched(ctx, "backend"); cabana.Allows(principal, []string).
</interfaces>
</context>
## Execution units and resume points
This plan creates a repository and has five tasks, so each task is a resumable unit and the plan can run across several executor contexts without losing work:
- Every task ends with the commit groups listed below, in order, in `../sm-seo-plugin`: `git -c safe.directory='*' -C ../sm-seo-plugin add <paths of the group>` (paths only, never `-A` or `.`), then `commit -m "<subject>"`. No task leaves uncommitted files for the next one, every commit keeps `go vet ./...` and `go test -short ./...` green in the plugin, and each group adds the lang keys it uses to both lang files.
- Resume check before each task: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'` for each subject of the task. All present: the task is done; re-run its `<verify>` and go on. Some present: continue at the first missing group. None: start the task.
- An executor whose context is getting heavy stops after a task's last commit and returns its completed-task table (task, subjects, SHAs); the continuation starts at the next task from that table. The Task 4 checkpoint is always such a boundary: Tasks 1 to 3 run in one context and Task 5 in a fresh one.
| Task | Commit groups, in order | Resume point after the task |
|------|-------------------------|-----------------------------|
| 1 | `feat: plugin scaffold, entries and the SEO tab tracer` | TestSEOTabTracer passes: the SEO tab saves a title and resolve returns it |
| 2 | A `feat: URL rules and the settings singleton`; B `feat: per-type settings tabs and the SEO tab permission`; C `feat: full precedence resolver with value sources` | rules, settings and the full resolver work; Task 3 builds on `classes/service.go` only |
| 3 | A `feat: SEO change history`; B `feat: entry generation job and cleanup on delete` | history, the generation job and the delete listener work; nothing of the element toolchain is installed |
| 4 | none (checkpoint); the continuation prompt carries the answer, and 32-03-SUMMARY.md records it | the four npm pins and grobotstxt approved or refused |
| 5 | A `feat: SEO field elements and bundle`; B `feat: SEO field types and the complete SEO tab`; C `docs: plugin README` | plan complete; write 32-03-SUMMARY.md |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor sets a record's SEO title in its SEO tab and the frontend's resolve answer returns it" — one path only</name>
<precondition>`git ls-remote git@git.golem15.com:golem15/sm-seo-plugin.git` exits 0 (the repository exists; empty output is fine); plans 32-01 and 32-02 are committed in summercms.go</precondition>
<reversibility rating="one-way">The entries table keyed by (model type, record id, locale) and the opt-in through signpost descriptors are what every content plugin builds on; confirmed by Jakub as D-01 (already decided, no checkpoint).</reversibility>
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/go.work, ../sm-seo-plugin/.gitignore, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/logger.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/models/registry.go, ../sm-seo-plugin/models/entry.go, ../sm-seo-plugin/updates/registry.go, ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go, ../sm-seo-plugin/classes/service.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/entries.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/testmain_test.go, ../sm-seo-plugin/fixture_test.go, ../sm-seo-plugin/seo_tab_test.go</files>
<read_first>
- ../sm-sitemap-plugin/plugin.go, admin.go, go.mod, go.work, .gitignore, testmain_test.go, models/registry.go, updates/registry.go, updates/202610110002_create_golem15_sitemap_settings.go
- ../sm-journal-plugin/routes.go and controllers/api/posts.go (handler shape, wire.WriteJSON)
- summercms.go/modules/pact/capabilities.go and modules/pact/content.go (seams from plans 32-01 and 32-02)
- summercms.go/modules/signpost/content.go
- summercms.go/modules/cabana/form_extensions_postgres_test.go (how an extension is exercised over HTTP)
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md and 32-02-SUMMARY.md (final seam shapes)
</read_first>
<action>
Per D-01, D-90, D-91, D-93, D-94 and P-12/P-13/P-15:
1. Repository (P-12): clone into `../sm-seo-plugin`, branch `main`; `go.mod` module `git.golem15.com/golem15/sm-seo-plugin`, go 1.27.0, requiring the framework at the latest existing tag and gorm, gormigrate, pgx, testcontainers at the versions sm-sitemap-plugin pins; tracked `go.work` with the framework replace; `.gitignore` with `node_modules/` and `go.work.sum` (the README is written in Task 5).
2. `logger.go`: `pluginLog = diary.For("seo")` (sm-sitemap-plugin pattern). `plugin.go`: `Plugin{app *backpack.App; service *classes.Service}`, `ID() "golem15.seo"`, `Requires() nil`, Register stores the app, Boot builds the service (DB, signpost registry, config) and fails with `golem15.seo: %w`; compile-time assertions for party.Plugin, pact.HasConfig, HasMigrations, HasModels, HasRoutes, HasAdminActionExtensions, HasLang.
3. `models/entry.go` + the entries migration (P-13); `classes/entries.go` upsert/load per (model_type, record_id) returning locale maps; `classes/service.go` the single service every writer calls.
4. `extensions.go`: `AdminActionExtensions()` reads `signpost` `ContentTypes()` lazily and returns, for each type with `Content.Controller`, an extension adding tab `golem15.seo::lang.tab.seo` (no anchor, P-17) with, for the tracer, one field `seo_title` of built-in type `mltext` (replaced by `mlseotext` in Task 5); Load returns `{"seo_title": map[locale]title}`; Save upserts the entry rows inside `in.DB` with the URL snapshot from signpost `Describe` (reading through the save transaction).
5. `classes/resolver.go` (tracer subset): `Resolver.Resolve(ctx, rawURL string) (Result, error)` normalises the input to a path, finds the owner through signpost `MatchPath`, loads the entry for the matched locale and answers the title with source `record`, else the content title with source `content`; unpublished or unmatched → `not_found`. `routes.go`: `GET /_seo/api/v1/resolve` (throttle `seo-resolve` bucket via Buckets with `surf.TrustedProxies`); `controllers/api/resolve.go` writes the P-15 JSON with `wire.WriteJSON`.
6. Tests: `testmain_test.go` copied from sm-sitemap-plugin (DB/user `golem15_seo`, `flag.Parse()` then `testing.Short()`); `fixture_test.go`: a fake content plugin `acme.pages` with admin controller `acme.pages.pages` (model with title, slug; a fields.yaml with tabs), a menu item type `acme-page` (`/pages/:slug`) with a Content descriptor; `TestSEOTabTracer` in `seo_tab_test.go`: boot both plugins with cabana and signpost, GET the form schema of `acme.pages.pages` (contains `seo_title` in tab `golem15.seo::lang.tab.seo`), PUT a page with `seo_title` {en: "Custom"}, then `GET /_seo/api/v1/resolve?url=/pages/<slug>` answers `seo.title` "Custom" and the owner table has no SEO column.
7. Commit (resume unit, see Execution units): stage exactly this task's `<files>` paths in `../sm-seo-plugin` with `git -c safe.directory='*'` and commit `feat: plugin scaffold, entries and the SEO tab tracer`. Do not push.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSEOTabTracer' -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: TestSEOTabTracer" 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `git -c safe.directory='*' -C ../sm-seo-plugin branch --show-current` prints `main` and `git -c safe.directory='*' -C ../sm-seo-plugin remote get-url origin` prints `git@git.golem15.com:golem15/sm-seo-plugin.git`
- `head -1 ../sm-seo-plugin/go.mod` prints `module git.golem15.com/golem15/sm-seo-plugin`
- `grep -n "replace git.golem15.com/golem15/summercms => ../summercms.go" ../sm-seo-plugin/go.work` prints one line
- `grep -n "UNIQUE (model_type, record_id, locale)\|unique (model_type, record_id, locale)" ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go` prints one line (case-insensitive variants accepted) and the DDL has `data jsonb NOT NULL DEFAULT '{}'`
- `go -C ../sm-seo-plugin test ./... -run TestSEOTabTracer -count=1` passes
- `git -c safe.directory='*' -C ../sm-seo-plugin ls-remote origin` prints nothing (the remote is still empty: nothing pushed)
</acceptance_criteria>
<done>The new plugin adds an SEO tab to a fake content type through D-91, stores the value in its own entries table and returns it from the public resolve endpoint; committed locally on main.</done>
</task>
<task type="auto">
<name>Task 2: URL rules, global and per-type settings (AdminSingleton + D-91 on itself) and the full precedence resolver</name>
<files>../sm-seo-plugin/models/settings.go, ../sm-seo-plugin/models/type_setting.go, ../sm-seo-plugin/models/rule.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/models/rule/fields.yaml, ../sm-seo-plugin/models/rule/columns.yaml, ../sm-seo-plugin/updates/202610270003_create_golem15_seo_settings.go, ../sm-seo-plugin/updates/202610270004_create_golem15_seo_type_settings.go, ../sm-seo-plugin/updates/202610270005_create_golem15_seo_rules.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/settings.go, ../sm-seo-plugin/controllers/settings/config_form.yaml, ../sm-seo-plugin/controllers/settings/_staging.htm, ../sm-seo-plugin/controllers/rules.go, ../sm-seo-plugin/controllers/rules/config_list.yaml, ../sm-seo-plugin/controllers/rules/config_form.yaml, ../sm-seo-plugin/admin.go, ../sm-seo-plugin/admin_permissions.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/classes/templates.go, ../sm-seo-plugin/classes/urls.go, ../sm-seo-plugin/classes/robots_value.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/settings_test.go, ../sm-seo-plugin/seo_tab_test.go</files>
<read_first>
- ../sm-sitemap-plugin/controllers/definitions.go (AdminSingleton), controllers/admin_registry.go, admin.go (Settings link entry), admin_permissions.go, models/settings.go, models/settings/fields.yaml
- ../sm-paymentgateway-plugin/controllers/orders.go 24-50, 106-155 (list/form controller hooks, FilterOptions, ListExtendRecords)
- summercms.go/docs/backend/settings.md and docs/backend/admin-controllers.md (Singleton controllers, line 453)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C2, C7, C11, Copywriting: SEO settings, URL rules)
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-02 to D-08, D-21, D-23)
- ../sm-seo-plugin/classes/resolver.go and extensions.go (Task 1)
</read_first>
<action>
Per D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-15, D-21, D-22, D-23, D-104 and P-13/P-14/P-15:
1. Models and migrations for settings (row 1 inserted by the migration with the seeded query parameters), type_settings, rules. Rule validation: url starts with "/", `*` only in a pattern (`rules.url_invalid`), unique (url, match) (`rules.url_taken`); settings: verification file names `^[A-Za-z0-9._-]+\.(html|txt|xml)$` (`settings.verify_filename_invalid`), unique names (`settings.verify_filename_taken`), templates with only known placeholders %title%, %description%, %site% (`settings.template_unknown`).
2. Controllers: `golem15.seo.settings` (`AdminSingleton`, tabs General, Indexing, Addresses, Verification per UI-SPEC C11, staging partial; the P3 placeholders partial comes with the other partials in Task 5); `golem15.seo.rules` (list + form per C7, delete confirms, `ListExtendRecords` builds the `overrides` column). The settings tile is a link entry (`pact.SettingsItem{Code: "seo_settings", Controller: "golem15.seo.settings", Icon: "search", Order: 100, ...}`, D-104); one permission `golem15.seo.manage` (D-15) on every controller and the tile; navigation item `seo` (icon `search`, order 600) landing on `golem15.seo.rules` for now with the side-menu item URL rules (Task 3 adds History when its controller exists; plan 32-04 re-points the item to Redirects per C2). `admin.go` embeds the admin files by glob (`controllers/*/*.yaml controllers/*/_*.htm models/*/*.yaml`) instead of file by file as sm-sitemap-plugin does, so later tasks and plans add screens and partials without editing it (Task 5 adds `extension/_*.htm`).
3. D-104: in `extensions.go` add one extension on `golem15.seo.settings` whose Tabs place one tab per content type (Name = the type's signpost Label key, the first After the General tab key, each next After the previous) and whose Fields are `type_<key>_inject` (switch, default on), `type_<key>_title_template`, `type_<key>_description_template`, `type_<key>_robots` (built-in dropdown until Task 5 swaps in `seorobots` with `scope: type`; defaults per D-21: a tag type `noindex,follow`, other types empty); Task 5 adds the `type_<key>_placeholders` P3 partial field. Load/Save read and upsert `type_settings`. The SEO tab extension's Visible reads `type_settings.inject` per request (answers true when it cannot decide).
4. Resolver complete per P-14: templates with placeholders (`classes/templates.go`); canonical building and the D-07 parameter list with path refinement (`classes/urls.go`; site URL from `golem15.seo.site_url`, fallback `app.url`); robots per directive with the value grammar of UI-SPEC C5 (`classes/robots_value.go`: parse and serialise in the fixed order, refuse malformed input with `pact.FormFieldError`); never-indexed and search paths (one pattern per line, `*` wildcard) for D-21/D-23; the X-Robots-Tag value equal to the robots directives; OG/Twitter values (D-04) with twitter:card summary_large_image when an image exists; verification meta on the home path (D-05); the non-production lock from `app.Config.Environment()` != "production" forcing noindex,nofollow and `staging: true` (D-22). `Result` carries a source per value for P2 and plan 32-05's read tool.
5. Tests (smoke): `TestResolverPrecedence` table in `settings_test.go` covering each P-14 chain, a pattern rule `/blog?tag=*`, the parameter list (utm stripped, page kept, sort → noindex,follow) and the staging lock; `TestSettingsTypeTabs` (two content types → General, type A, type B, Indexing, Addresses, Verification; zero types → four fixed tabs); `TestSEOTabPermission` in `seo_tab_test.go` (an admin without golem15.seo.manage sees no SEO tab and a posted seo_title changes nothing).
6. Commits (resume units, see Execution units), staged by path in `../sm-seo-plugin` (paths relative to it), in this order:
- A `feat: URL rules and the settings singleton`: models/settings.go, models/rule.go, models/settings/fields.yaml, models/rule/fields.yaml, models/rule/columns.yaml, the settings and rules migrations, controllers/admin_registry.go, controllers/settings.go, controllers/settings/config_form.yaml, controllers/settings/_staging.htm, controllers/rules.go, controllers/rules/config_list.yaml, controllers/rules/config_form.yaml, admin.go, admin_permissions.go, admin_navigation.go, lang/en/lang.yaml, lang/pl/lang.yaml;
- B `feat: per-type settings tabs and the SEO tab permission`: models/type_setting.go, the type_settings migration, extensions.go, lang files, settings_test.go (TestSettingsTypeTabs), seo_tab_test.go (TestSEOTabPermission);
- C `feat: full precedence resolver with value sources`: classes/resolver.go, classes/templates.go, classes/urls.go, classes/robots_value.go, config/config.yaml, settings_test.go (TestResolverPrecedence).
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestResolverPrecedence|TestSettingsTypeTabs|TestSEOTabPermission|TestSEOTabTracer' -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 four test names 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Controller: *\"golem15.seo.settings\"\|Controller: *controllers.SettingsControllerID" ../sm-seo-plugin/admin.go` prints the settings tile link entry, and `grep -c "Form:" ../sm-seo-plugin/admin.go` prints 0 for the SettingsItem (link entry, D-104)
- `grep -n "golem15.seo.manage" ../sm-seo-plugin/admin_permissions.go` prints one line
- `grep -n "Environment()" ../sm-seo-plugin/classes/*.go` prints the staging-lock decision, and no admin field or config key disables it (`grep -rn "staging_lock\|disable_staging" ../sm-seo-plugin --include=*.go --include=*.yaml` prints nothing)
- `TestResolverPrecedence` has at least one case per value chain of P-14 and passes
- `grep -rn "r.Host\|req.Host" ../sm-seo-plugin/classes ../sm-seo-plugin/controllers` prints nothing
</acceptance_criteria>
<done>URL rules, the settings singleton with dynamic per-type tabs on D-91, one permission and the full precedence resolver with sources work and are smoke-tested.</done>
</task>
<task type="auto">
<name>Task 3: History core, entry generation job, entry cleanup on delete</name>
<files>../sm-seo-plugin/models/revision.go, ../sm-seo-plugin/models/revision/fields.yaml, ../sm-seo-plugin/models/revision/columns.yaml, ../sm-seo-plugin/updates/202610270002_create_golem15_seo_revisions.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/classes/generate.go, ../sm-seo-plugin/classes/service.go, ../sm-seo-plugin/controllers/history.go, ../sm-seo-plugin/controllers/history/config_list.yaml, ../sm-seo-plugin/controllers/history/config_form.yaml, ../sm-seo-plugin/controllers/history/_history_diff.htm, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/controllers/settings.go, ../sm-seo-plugin/jobs.go, ../sm-seo-plugin/commands.go, ../sm-seo-plugin/listeners.go, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/seo_tab_test.go</files>
<read_first>
- ../sm-chat-plugin/plugin.go 85-100 (conga.Job, OnQueue, MaxAttempts) and its jobs package
- summercms.go/modules/pact/capabilities.go (HasJobs, HasCommands) and docs/plugins/scheduling.md
- summercms.go/modules/bouncer/dispatch.go (Dispatched) and modules/towel (actor from context)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C12 History, P5, Copywriting: History)
- ../sm-seo-plugin/classes/service.go
</read_first>
<action>
Per D-13, D-92, D-94:
1. History core: every service write (entry, rule, settings, type settings) appends one revision row per changed object with author id/label from the towel actor, `via` = `mcp` when `bouncer.Dispatched(ctx, "backend")` answers true else `admin` (import/auto/undo set by later plans), `what` built from the UI-SPEC `history.what_*` keys, before/after jsonb of the changed fields only, all inside the caller's transaction. History screen `golem15.seo.history` per C12: list (columns, filters via/created_at/author, `history.list_empty`, row state `disabled` for undone), preview with P5 `history_diff` (Field / Before / After, DM Mono for URL values, created/deleted markers), and its side-menu entry History in `admin_navigation.go`. Undo record actions are plan 32-05.
2. GenerateEntries (D-94): `jobs.go` registers `conga.Job` for `GenerateEntriesArgs{Type string}`; `classes/generate.go` pages signpost `Records` per content type (limit 200) and upserts entry rows with each locale's URL, type key and the effective-title/description cache, never overwriting editor values; `commands.go` adds the console command `seo:generate-entries` with an optional `--type` flag that enqueues the job; the settings singleton's FormAfterUpdate (`controllers/settings.go`) enqueues it too.
3. `listeners.go` (registered in Boot): `*pact.RecordDeleted` for a model with a content type deletes its entry rows inside a transaction and records a `deleted` revision so the values stay in history (D-92, D-94); `*pact.RecordSaved` with Created true upserts the URL snapshot of the new record (address-change redirects are plan 32-04).
4. Tests (smoke) in `seo_tab_test.go`: `TestHistoryRecordsAdminWrite` (saving the SEO tab writes one revision with via admin and before/after), `TestEntryDeletedWithRecord`, `TestGenerateEntries` (two fake records → two entry rows with URLs; editor values untouched on a second run).
5. Commits (resume units, see Execution units), staged by path in `../sm-seo-plugin`, in this order:
- A `feat: SEO change history`: models/revision.go, models/revision/fields.yaml, models/revision/columns.yaml, the revisions migration, classes/history.go, classes/service.go, controllers/history.go, controllers/history/config_list.yaml, controllers/history/config_form.yaml, controllers/history/_history_diff.htm, controllers/admin_registry.go, admin_navigation.go, lang files, seo_tab_test.go (TestHistoryRecordsAdminWrite);
- B `feat: entry generation job and cleanup on delete`: classes/generate.go, jobs.go, commands.go, listeners.go, plugin.go, controllers/settings.go, lang files, seo_tab_test.go (TestEntryDeletedWithRecord, TestGenerateEntries).
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestHistoryRecordsAdminWrite|TestEntryDeletedWithRecord|TestGenerateEntries' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or any of the three names 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "\*pact.RecordDeleted" ../sm-seo-plugin/listeners.go` prints one line
- `grep -n "seo:generate-entries" ../sm-seo-plugin/commands.go` prints one line
- `grep -n "bouncer.Dispatched" ../sm-seo-plugin/classes/history.go` prints one line
- The three tests pass with Docker
</acceptance_criteria>
<done>Every admin SEO write is recorded in history with author, via and before/after; entries are generated by a conga job and removed with their record while history keeps the values.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 4: Package legitimacy gate for the element toolchain and the robots.txt library</name>
<what-built>Nothing is installed yet. RESEARCH.md's Package Legitimacy Audit flags the npm dev dependencies vite, vitest and happy-dom as [SUS] (their latest releases are days old; the pins are older) and the Go module github.com/jimsmart/grobotstxt as [ASSUMED] (the Go ecosystem is not covered by the legitimacy seam). typescript is approved. The pins are the ones sm-journal-plugin already locks (typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6); grobotstxt v1.0.3 is the version D-99 names and plan 32-04 installs.</what-built>
<how-to-verify>
1. Open https://www.npmjs.com/package/vite/v/7.3.5, https://www.npmjs.com/package/vitest/v/3.2.7 and https://www.npmjs.com/package/happy-dom/v/20.11.6: the publisher and repository match vitejs/vite, vitest-dev/vitest and capricorn86/happy-dom.
2. Run `npm --prefix ../sm-journal-plugin ls vite vitest happy-dom typescript` and confirm the same versions are already locked in an approved sibling plugin.
3. Open https://pkg.go.dev/github.com/jimsmart/grobotstxt@v1.0.3 and https://github.com/jimsmart/grobotstxt: Apache-2.0, a port of Google's robots.txt parser; `go list -m -json github.com/jimsmart/grobotstxt@v1.0.3` resolves through proxy.golang.org.
</how-to-verify>
<resume-signal>Type "approved" to install exactly these pins (Task 5 here, grobotstxt in plan 32-04), or name the package to refuse</resume-signal>
</task>
<task type="auto">
<name>Task 5: Plugin field types and elements E1, E2, E3, E7, partials P1/P2/P3 and the complete SEO tab (UI-SPEC C3-C6)</name>
<precondition>Task 4 answered "approved" for typescript 5.9.3, vite 7.3.5, vitest 3.2.7 and happy-dom 20.11.6</precondition>
<files>../sm-seo-plugin/package.json, ../sm-seo-plugin/package-lock.json, ../sm-seo-plugin/tsconfig.json, ../sm-seo-plugin/vite.config.ts, ../sm-seo-plugin/scripts/check-dist.sh, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/measure.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/text.ts, ../sm-seo-plugin/assets/src/elements/robots.ts, ../sm-seo-plugin/assets/src/elements/serp.ts, ../sm-seo-plugin/assets/src/elements/image.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/field_types.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/classes/image.go, ../sm-seo-plugin/classes/snippet.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/extension/_seo_effective.htm, ../sm-seo-plugin/extension/_placeholders.htm, ../sm-seo-plugin/controllers/settings/_placeholders.htm, ../sm-seo-plugin/controllers/rules/_seo_status.htm, ../sm-seo-plugin/controllers/rules/_seo_effective.htm, ../sm-seo-plugin/models/rule/fields.yaml, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/admin.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/seo_tab_test.go</files>
<read_first>
- ../sm-journal-plugin/package.json, vite.config.ts, tsconfig.json, scripts/check-dist.sh, assets/src/blogeditor/ (index.ts, labels.ts, element structure), field_types.go, classes/blog_editor.go (80-109 type pair, 343-380 Upload)
- ../sm-paymentgateway-plugin/assets/css/quote-cart.css (scoped element CSS with --c-* variables)
- summercms.go/admin/src/components/form/fields/WidgetField.vue and PluginField.vue (summer-action, summer-change, fill-values, busy contracts)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (Component Inventory E1-E3, C1, C3, C4, C5, C6, C14, Copywriting tables for the SEO tab, P1, P2, E1, E2, E3, Typography E-1, Color E-1)
- ../sm-seo-plugin/extensions.go and classes/resolver.go
</read_first>
<action>
Per D-04, D-06, D-08, D-09, D-100 and UI-SPEC:
1. Element bundle: `package.json` (name `golem15-seo-elements`, private, type module, scripts typecheck/build/test, devDependencies pinned exactly as approved, no runtime dependencies); `npm --prefix ../sm-seo-plugin install --save-dev --save-exact` with those pins; `vite.config.ts` copying Journal's (entry `assets/src/elements/index.ts`, outputs `seo.js` and `seo.css`, vitest with happy-dom and `tests/**/*.test.ts`); `scripts/check-dist.sh` copying Journal's drift gate. Elements in light DOM with CSS scoped under each tag and `--c-*` variables only (E-1 palette only inside `golem15-seo-serp .serp-card`): E1 `golem15-seo-text` (C4, shared `measure.ts` canvas measurement, thresholds of Q-3), E2 `golem15-seo-robots` (C5, value grammar identical to `classes/robots_value.go`), E3 `golem15-seo-serp` (C6: context and duplicates ops, desktop/mobile, pixel truncation, keyphrase checks with the C6 stem rule, one queued request while busy), E7 `golem15-seo-image` (upload through the field type's Upload, preview, remove). `labels.ts` holds the en/pl element copy of UI-SPEC with CLDR plural maps. `npm run build` and commit `assets/dist`.
2. Field types (`field_types.go` + `classes/field_types.go`): `seotext`/`mlseotext` (option `measure`), `seorobots`/`mlseorobots` (option `scope`, Decode refuses a malformed directive string), `seoimage` (P-16 Upload into `*blob.Bucket`, folder `seo`, image content sniffing, `FormFieldError` on a non-image). Each lists `assets/dist/seo.js` and `assets/dist/seo.css` (if cabana refuses the same asset in several types, list it on `mlseotext` only and record that in the SUMMARY). The extension's `seo_snippet` widget action lives in `AdminFormExtension.Actions` (`classes/snippet.go`: `context` answers locales with url, h1, siteName, titleTemplate, defaultTitle, defaultDescription, descriptionSource, bodyWords and host; `duplicates` answers other records' and rules' labels and admin URLs whose effective title or description equals the submitted one in that locale).
3. Complete SEO tab per C3 (fields `seo_status` P1, `seo_snippet` E3, `seo_title` mlseotext, `seo_description` mlseotext, `seo_keyphrase` mltext left, `seo_canonical` mltext right with the `validation.url` check for a path or http(s) URL, `seo_robots` mlseorobots scope record, `seo_og_title`, `seo_og_description` mltextarea small, `seo_og_image` seoimage, `seo_effective` P2), partials from `extension/` through `Dir` and `PartialData` (P1: URL rule override and canonical-to-noindex callouts; P2: effective values per locale with source labels and the staging note); the rule form and settings use `seotext`, `seorobots` and `seoimage` where C7/C11 name text, robots and image fields; the P3 placeholders partial on General (`controllers/settings/_placeholders.htm`) and on each type tab (the `type_<key>_placeholders` field of the settings extension, `extension/_placeholders.htm`); `admin.go` adds `extension/_*.htm` to its embed patterns.
4. Full en/pl `lang.yaml` for every key of the UI-SPEC sections this plan renders (navigation for rules/history, settings tile, permission, SEO tab, P1, P2, SEO settings, URL rules, History).
5. Smoke: `TestSEOTab` in `seo_tab_test.go`: with the fake content plugin carrying a tabbed form and a tab-less form, the compiled schema lists the C3 fields in order inside the SEO tab placed last, the tab-less form has default then SEO, a malformed `seo_robots` value answers 422, and `seo_og_image` upload rejects a non-image. `npm --prefix ../sm-seo-plugin run typecheck && npm --prefix ../sm-seo-plugin run build` succeed (element unit tests are plan 32-06). README completed in the standard structure (overview, features, usage for content plugins: register a descriptor with Controller and Model, config keys `golem15.seo.site_url`, the resolve endpoint, permissions).
6. Commits (resume units, see Execution units), staged by path in `../sm-seo-plugin`, in this order:
- A `feat: SEO field elements and bundle`: package.json, package-lock.json, tsconfig.json, vite.config.ts, scripts/check-dist.sh, assets/src/elements/index.ts, measure.ts, labels.ts, text.ts, robots.ts, serp.ts, image.ts, styles.css, assets/dist/seo.js, assets/dist/seo.css;
- B `feat: SEO field types and the complete SEO tab`: field_types.go, classes/field_types.go, classes/image.go, classes/snippet.go, extensions.go, extension/_seo_status.htm, extension/_seo_effective.htm, extension/_placeholders.htm, controllers/settings/_placeholders.htm, controllers/rules/_seo_status.htm, controllers/rules/_seo_effective.htm, models/rule/fields.yaml, models/settings/fields.yaml, admin.go, lang/en/lang.yaml, lang/pl/lang.yaml, seo_tab_test.go (TestSEOTab);
- C `docs: plugin README`: README.md.
</action>
<verify>
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit, a TypeScript error line, or "assets/dist is stale" in the output</fails_when>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSEOTab' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>a "FAIL" line, "no tests to run", or no "--- PASS: TestSEOTab" line</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "\"vite\": \"7.3.5\"\|\"vitest\": \"3.2.7\"\|\"happy-dom\": \"20.11.6\"\|\"typescript\": \"5.9.3\"" ../sm-seo-plugin/package.json` prints four lines and `grep -c "\"dependencies\"" ../sm-seo-plugin/package.json` prints 0
- `grep -c "customElements.define" ../sm-seo-plugin/assets/src/elements/*.ts` totals 4 (E1, E2, E3, E7)
- `grep -n "Name: *\"seoimage\"" ../sm-seo-plugin/classes/field_types.go` prints one line and `grep -n "fileupload" ../sm-seo-plugin/extensions.go ../sm-seo-plugin/models/rule/fields.yaml ../sm-seo-plugin/models/settings/fields.yaml` prints nothing (D-100)
- `grep -n "seo_robots\|seo_og_image" ../sm-seo-plugin/extensions.go` prints both reserved anchor names
- every `golem15.seo::lang.` key referenced in `../sm-seo-plugin` YAML and Go exists in both `lang/en/lang.yaml` and `lang/pl/lang.yaml` (a small Go test `TestLangKeysComplete` or the framework's phrase validation at boot passes)
</acceptance_criteria>
<done>The SEO tab matches UI-SPEC C3 with working counters, robots directives, the live result preview with duplicate and keyphrase checks, and an uploadable social image; the bundle is built, committed and drift-checked.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| anonymous frontend → GET /_seo/api/v1/resolve | public, throttled read of effective SEO by URL |
| admin browser → SEO tab, rules, settings, seoimage upload | authenticated writes behind golem15.seo.manage |
| stored SEO strings → admin partials and elements | editor-supplied text rendered back in the admin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-09 | Information disclosure | resolve endpoint | high | mitigate | Answers only for records whose descriptor reports Published; drafts and unknown paths answer not_found (TestResolverPrecedence draft case) |
| T-32-10 | Spoofing | canonical and absolute URLs | high | mitigate | Built from golem15.seo.site_url or app.url only; acceptance greps that no code reads the request Host |
| T-32-11 | Tampering | SEO strings in P1/P2/P3/P5 and E3 | medium | mitigate | html/template escaping plus cabana's sanitizePartialNodes; elements set textContent, never innerHTML with stored values |
| T-32-12 | Tampering | seoimage upload | medium | mitigate | Content sniffing to image types only, the framework upload body limit, a fixed folder and a sanitized file name (Journal editor upload pattern) |
| T-32-13 | Elevation of privilege | SEO tab and SEO screens | high | mitigate | golem15.seo.manage on every controller, the tile and the extension (Permissions); posted SEO keys ignored without it (TestSEOTabPermission) |
| T-32-14 | Denial of service | resolve endpoint | medium | mitigate | surf throttle bucket seo-resolve with trusted-proxy client IPs; URL length capped at 2048 bytes |
| T-32-15 | Information disclosure | non-production sites indexed | high | mitigate | Environment-based lock forces noindex,nofollow in every resolve answer; no setting can disable it (D-22) |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | Task 4 blocking-human legitimacy checkpoint before installing vite, vitest, happy-dom (SUS) and grobotstxt (ASSUMED); exact pins already locked by an approved sibling; postinstall scripts are null |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker; `-short` green without.
- `npm --prefix ../sm-seo-plugin run typecheck`, `scripts/check-dist.sh` green.
- `grep -rn "sm-seo-plugin" ../sm-journal-plugin --include=*.go` empty; framework unchanged by this plan.
- Nothing pushed: `git -c safe.directory='*' -C ../sm-seo-plugin status -sb` shows main with no upstream or ahead of an empty origin.
</verification>
<success_criteria>
Any content type registered with a descriptor gets the SEO tab without importing the plugin, editors manage URL rules and global/per-type settings, and the frontend can ask for the effective SEO of any URL with the D-02 precedence and per-value sources.
</success_criteria>
## Artifacts this phase produces (plan 03)
- Repository `../sm-seo-plugin` (module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, plugin `golem15.seo`), branch `main`
- Tables `golem15_seo_entries`, `golem15_seo_revisions`, `golem15_seo_settings`, `golem15_seo_type_settings`, `golem15_seo_rules`; models `Entry`, `Revision`, `Settings`, `TypeSetting`, `Rule`
- Controllers `golem15.seo.settings` (AdminSingleton), `golem15.seo.rules`, `golem15.seo.history`; permission `golem15.seo.manage`; navigation `seo`; settings tile `seo_settings`
- Field types `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage`; elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp`, `golem15-seo-image`; widget action `seo_snippet`; partials `seo_status`, `seo_effective`, `placeholders`, `history_diff`, `staging`
- `classes.Service`, `classes.Resolver` (`Resolve`), templates, URL/canonical builder, robots value grammar, history writer, generator
- Route `GET /_seo/api/v1/resolve`, throttle bucket `seo-resolve`, config key `golem15.seo.site_url`
- Job `GenerateEntriesArgs` via conga, console command `seo:generate-entries`, listeners for `*pact.RecordDeleted` and created `*pact.RecordSaved`
- Tests `TestSEOTabTracer`, `TestSEOTab`, `TestSEOTabPermission`, `TestResolverPrecedence`, `TestSettingsTypeTabs`, `TestHistoryRecordsAdminWrite`, `TestEntryDeletedWithRecord`, `TestGenerateEntries`
<output>
Create `.planning/phases/32-seo-foundation/32-03-SUMMARY.md` when done, listing the sm-seo-plugin commit SHAs per task and commit group, the framework tag go.mod requires, the Task 4 answer and the cabana asset-dedup observation.
</output>

View File

@@ -0,0 +1,364 @@
---
phase: 32-seo-foundation
plan: 03
subsystem: sm-seo-plugin
tags: [seo, sm-seo-plugin, cabana, form-extensions, signpost, resolver, history, conga, custom-elements, vite]
requires:
- phase: 32-01
provides: pact.AdminFormExtension (yaml-document form, FieldsYAML, Dir, Actions, PartialData, Load, Save, Visible, Permissions)
- phase: 32-02
provides: pact.RecordSaved/RecordDeleted, AdminFormSave.Changes, pact.ContentDescriptor and the signpost ContentTypes/ContentType/ContentTypeForController/ContentTypeForModel/Describe/Records/MatchPath/MatchPattern lookups
provides:
- "New repository ../sm-seo-plugin (module git.golem15.com/golem15/sm-seo-plugin, package seo, plugin golem15.seo), branch main, 9 local commits, nothing pushed"
- "Tables golem15_seo_entries (unique model_type, record_id, locale; data jsonb), golem15_seo_revisions, golem15_seo_settings (row 1, seeded D-07 list), golem15_seo_type_settings, golem15_seo_rules (unique url, match)"
- "SEO tab on every content type with Content.Controller through one yaml-document AdminFormExtension per type (UI-SPEC C3 field order, anchors seo_robots and seo_og_image reserved)"
- "Settings singleton golem15.seo.settings (settings tile link entry seo_settings) with per-type tabs added by the plugin's own FieldsYAML extension (D-104)"
- "URL rules golem15.seo.rules and history golem15.seo.history screens; navigation item seo; permission golem15.seo.manage"
- "classes.Service (single writer, history), classes.Resolver.Resolve/ResolveRecord with Result.Sources, robots grammar, templates, canonical and D-07 parameter handling, non-production lock"
- "GET /_seo/api/v1/resolve (bucket seo-resolve), config keys golem15.seo.site_url and golem15.seo.unprefixed_locale"
- "GenerateEntriesArgs conga job, seo:generate-entries command, listeners for *pact.RecordDeleted and created *pact.RecordSaved"
- "Field types seotext, mlseotext, seorobots, mlseorobots, seoimage; elements golem15-seo-text, golem15-seo-robots, golem15-seo-serp, golem15-seo-image; widget action seo_snippet; partials seo_status, seo_effective, placeholders, type_placeholders, history_diff, staging"
affects: [32-04, 32-05, 32-06, 33, sm-sitemap-plugin, vue-seo-layer]
actuals:
tokens: 87600
tasks: 5
commits: 9
plan_head_before: 1d3fa326162605533bd8ce4bf9702c05f1f92c9f
plan_head_after: 1d3fa326162605533bd8ce4bf9702c05f1f92c9f
tech-stack:
added:
- "npm dev dependencies (exact pins, approved in Task 4): typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6"
- "Go: no new module outside the decided set (gorm, gormigrate, pgx, testcontainers, gocloud.dev/blob, goccy/go-yaml are framework/sibling dependencies)"
patterns:
- "Field-less self-extension on the plugin's own controllers (rules, settings) to record history from AdminFormSave.Changes inside the save transaction"
- "Per-type settings tabs as a FieldsYAML document built at boot from signpost ContentTypes (tab chain after General)"
- "Validation messages with :params translated in the controller hook (FormBeforeCreate/Update) because cabana localizes ValidationError details without params"
- "Fake TranslationWriter in the test fixture plugin (en default, pl enabled) instead of a dependency on sm-translate-plugin"
key-files:
created:
- ../sm-seo-plugin/plugin.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/extensions_settings.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/entries.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/classes/generate.go
- ../sm-seo-plugin/classes/snippet.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/robots_value.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/updates/202610270001_create_golem15_seo_entries.go
- ../sm-seo-plugin/assets/src/elements/serp.ts
- ../sm-seo-plugin/assets/dist/seo.js
- ../sm-seo-plugin/seo_tab_test.go
- ../sm-seo-plugin/settings_test.go
- ../sm-seo-plugin/README.md
modified: []
key-decisions:
- "Task 4 (package legitimacy, gate blocking-human) answered by Jakub before dispatch: approved. Installed exactly typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6 (npm install --save-exact --ignore-scripts); grobotstxt v1.0.3 is approved for plan 32-04"
- "D-91 used in its published yaml-document form: the SEO tab is one FieldsYAML document (tabs: [golem15.seo::lang.tab.seo], no anchor) and the per-type settings tabs are a FieldsYAML document built at boot; pact.AdminFormTab/AdminFormField do not exist"
- "The resolve endpoint answers HTTP 200 for both ok and not_found (the answer's status field carries 404); 422 for a url that is neither a path nor an http(s) URL; every answer carries X-Robots-Tag: noindex"
- "The home page / is always a known page (home template, verification meta) even without a rule or record"
- "URL rules apply per value, most specific first: exact rules, then patterns by address length; a rule address with ? is matched against path and query"
- "Unknown query parameters are dropped from the canonical without noindex; a path-scoped parameter row wins over a site-wide one"
- "Robots are serialised with \",\" when stored and \", \" in the meta/X-Robots-Tag answer; the robots source is the layer that decided indexing"
- "History stores What as text rendered in the app locale (searchable); via comes from bouncer.Dispatched (mcp) or a WithVia mark (import, auto, undo); entry deletes on RecordDeleted are recorded with via auto"
- "The social image (seo_og_image) is one value per record written to every locale row (Q-5); seoimage uploads require golem15.seo.manage"
- "Per-type settings and SEO tab field labels carry no :type parameter (fields.yaml labels take none; the tab title names the type)"
patterns-established:
- "sm-seo-plugin controllers get the service on demand with classes.NewService(app) (no DB needed at boot, so console commands boot without a database)"
- "Partials return curated view models with text already translated (classes.StatusView, EffectiveView, PlaceholdersView)"
requirements-completed: [SC-2, D-01, D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-15, D-21, D-22, D-23, D-90, D-91, D-92, D-93, D-94, D-95, D-100, D-104, D-13]
coverage:
- id: D1
description: "SEO tab tracer: a content type the plugin does not know gets the SEO tab through D-91; a saved SEO title lands in golem15_seo_entries and GET /_seo/api/v1/resolve returns it with absolute URLs from the site URL; the owner table has no SEO column"
requirement: "SC-2"
verification:
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestSEOTabTracer"
status: pass
human_judgment: false
- id: D2
description: "Precedence per value (rule > record > type/general template > content > settings) with sources; exact and pattern rules; D-07 parameter list incl. path scope; never-indexed and search paths; tag-type default robots; home template and verification meta; drafts and unknown paths not_found; non-production lock"
requirement: "D-02"
verification:
- kind: integration
ref: "../sm-seo-plugin/settings_test.go#TestResolverPrecedence"
status: pass
human_judgment: false
- id: D3
description: "Settings singleton with one tab per content type in registration order between General and Indexing (four fixed tabs with none), per-type defaults, template placeholder refusal, inject switch hiding the SEO tab while stored values keep working"
requirement: "D-104"
verification:
- kind: integration
ref: "../sm-seo-plugin/settings_test.go#TestSettingsTypeTabs"
status: pass
human_judgment: false
- id: D4
description: "One permission golem15.seo.manage: without it no SEO fields in schema or show and a posted seo_title changes nothing; seoimage upload refused"
requirement: "D-15"
verification:
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestSEOTabPermission"
status: pass
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestSEOTab"
status: pass
human_judgment: false
- id: D5
description: "History of admin writes (SEO tab, rule create/update/delete, settings and type settings) with author, via, changed fields before/after; history list and P5 diff; via mcp under an MCP dispatch"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestHistoryRecordsAdminWrite"
status: pass
human_judgment: false
- id: D6
description: "Created records get their address snapshot; deleting a record removes its entries after commit and history keeps the values (via auto); GenerateEntries creates entries per published record and locale and refreshes addresses without touching editor values"
requirement: "D-94"
verification:
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestEntryDeletedWithRecord"
status: pass
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestGenerateEntries"
status: pass
human_judgment: false
- id: D7
description: "Complete SEO tab (C3 order, last tab on a tabbed form, after default on a tab-less form), malformed robots and canonical 422, seoimage upload refuses a non-image and stores one image for every locale, seo_snippet context and duplicates, P1 rule callout and P2 effective values; URL rule form partials and preview; every lang key present in en and pl"
requirement: "D-09"
verification:
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestSEOTab"
status: pass
- kind: integration
ref: "../sm-seo-plugin/seo_tab_test.go#TestRuleFormPreview"
status: pass
- kind: unit
ref: "../sm-seo-plugin/seo_tab_test.go#TestLangKeysComplete"
status: pass
- kind: other
ref: "npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh"
status: pass
human_judgment: false
- id: D8
description: "Elements E1 counter, E2 robots editor, E3 result preview (pixel truncation, duplicates, Q-4 keyphrase stems), E7 image upload look and behave as UI-SPEC C4-C6/C14 in the real admin SPA (layout, dark mode, keyboard, locale switcher)"
verification: []
human_judgment: true
rationale: "Visual and interaction behaviour inside the SPA; only a throwaway happy-dom smoke ran here, element unit tests are plan 32-06"
duration: 48min
completed: 2026-10-27
status: complete
---
# Phase 32 Plan 03: SEO plugin core Summary
**New sm-seo-plugin (golem15.seo): an SEO tab injected into every signpost content type through the D-91 yaml-document extension, URL rules, a settings singleton with per-type tabs, change history, a conga entry-generation job, and the public GET /_seo/api/v1/resolve answering the effective SEO of any URL with the rule > record > template/global > content precedence and a source per value.**
## Task 4 answer (package legitimacy)
Jakub answered Task 4 (checkpoint:human-verify, gate blocking-human) before this run: **approved**. The orchestrator checked three things: sm-journal-plugin locks the same versions, the npm packages are published from vitejs/vite, vitest-dev/vitest and capricorn86/happy-dom through GitHub Actions, and grobotstxt resolves through proxy.golang.org.
- Installed exactly `typescript 5.9.3`, `vite 7.3.5`, `vitest 3.2.7` and `happy-dom 20.11.6` as dev dependencies, with `--save-exact --ignore-scripts`. `package.json` has no `dependencies` block.
- `npm install` reported audit advisories in the dev toolchain. Journal locks the same pins. Nothing was upgraded.
- `github.com/jimsmart/grobotstxt v1.0.3` is approved for plan 32-04. It was not installed here.
## Framework tag in go.mod
`go.mod` requires `git.golem15.com/golem15/summercms v0.5.0`, the latest existing tag. The 32-01 and 32-02 seams are not in v0.5.0 yet. Both `go.mod` and the tracked `go.work` therefore replace the framework with `../summercms.go`, as the sibling plugins do. Plan 32-06 bumps the require to the new patch tag.
## Cabana asset-dedup observation
Cabana accepted `assets/dist/seo.js` and `seo.css` listed on all five SEO field types without a boot refusal, so every type lists them. The rules controller also declares them through `pact.AdminClientAssets`, because a `type: widget` field on an owner form needs controller-declared JS.
## Performance
- **Duration:** 48 min
- **Started:** 2026-10-27T09:16:35Z
- **Completed:** 2026-10-27T10:05:19Z
- **Tasks:** 5 (Task 4 was answered before dispatch)
- **Files:** 89 tracked files in the new repository
## Accomplishments
- **Plugin scaffold.** Built on the 32-01/32-02 seams only. Content plugins import nothing: the fixture `acme.pages` registers descriptors and gets the SEO tab.
- **Effective-SEO resolver.** Covers:
- a source for each value;
- canonical URLs built from `golem15.seo.site_url`;
- the D-07 parameter list;
- robots per directive in the C5 grammar;
- social and verification values;
- a non-production lock that depends on the environment only.
- **Admin:**
- the SEO tab (C3);
- URL rules (C7);
- settings (C11) with per-type tabs;
- history (C12) with the P5 diff;
- the navigation item, the settings tile and one permission.
- **History and entries.** History records every admin SEO write. A record's entries are removed when it is deleted, and a new record gets an address snapshot. The generation job and its command create missing entries.
- **Element bundle.** E1, E2, E3 and E7 with en/pl labels, built, committed and guarded by a drift check.
## Task Commits (../sm-seo-plugin, branch main, not pushed)
1. **Task 1: tracer**: `fb9590f` feat: plugin scaffold, entries and the SEO tab tracer
2. **Task 2:**
- A `ce60c8a` feat: URL rules and the settings singleton
- B `30edb8a` feat: per-type settings tabs and the SEO tab permission
- C `9f1082d` feat: full precedence resolver with value sources
3. **Task 3:**
- A `e8a6b9f` feat: SEO change history
- B `ab8905c` feat: entry generation job and cleanup on delete
4. **Task 4:** no commit (approved before dispatch, recorded above)
5. **Task 5:**
- A `deebd9a` feat: SEO field elements and bundle
- B `93ddbcc` feat: SEO field types and the complete SEO tab
- C `b38eb4b` docs: plugin README
Tracer feedback gate: the run was interactive, in end-of-phase mode, with an automated-only verify. TestSEOTabTracer, vet and the -short tests re-ran green before Task 2.
`commits: 9` is counted in ../sm-seo-plugin with `git rev-list --count HEAD` (the repository started empty). summercms.go gets only this SUMMARY's docs commit, so its `plan_head_before` and `plan_head_after` are the same SHA, taken before that commit.
## Files Created
- Root: `plugin.go`, `logger.go`, `admin.go`, `admin_permissions.go`, `admin_navigation.go`, `routes.go`, `extensions.go`, `extensions_settings.go`, `field_types.go`, `jobs.go`, `commands.go`, `listeners.go`.
- `models/`: entry, revision, settings, type_setting and rule, with their YAML.
- `updates/`: five raw-SQL migrations.
- `classes/`: service, entries, resolver, templates, urls, robots_value, type_settings, history, generate, snippet, partials, image, field_types.
- `controllers/`: admin_registry, settings, rules, history and api/resolve, with their YAML and partials.
- `extension/`: the SEO tab partials and the per-type partial.
- Element toolchain:
- sources `assets/src/elements/*` and the build `assets/dist/seo.{js,css}`;
- `package.json`, `package-lock.json`, `tsconfig.json` and `vite.config.ts`;
- `scripts/check-dist.sh`.
- Tests: `testmain_test.go`, `fixture_test.go`, `seo_tab_test.go`, `settings_test.go`.
- `README.md`.
## Acceptance-criteria adjustments (yaml-document D-91 and other notes)
- **Task 1, go.mod:** `go.mod` also carries the `replace`, as Journal and Sitemap do. The go.work criterion holds.
- **Task 1, locale map:** the tracer PUT sends `seo_title` as a locale map. cabana refuses locale maps without a `cabana.TranslationWriter`, so the fixture publishes a fake one (en default, pl enabled).
- **Task 1, tab label:** the schema serves translated tab labels. The tracer compares the tab with "SEO", the label of `golem15.seo::lang.tab.seo`.
- **Task 2:** `grep golem15.seo.manage admin_permissions.go` matches the doc comment. The code uses `controllers.PermissionManage`.
- **Task 3, command grep:** `grep -n "seo:generate-entries" commands.go` prints four lines: the name, the comment and two examples.
- **Task 3, Dispatched grep:** `grep -n "bouncer.Dispatched" classes/history.go` prints one line, as required.
- **Task 5:** the extension's per-type placeholder partial is `extension/_type_placeholders.htm` (path `type_placeholders`), not `_placeholders.htm`. cabana refuses an extension partial name that the owner form already uses, and the settings form owns `placeholders`.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] config/config.yaml shipped in Task 1**
- **Issue:** the plugin asserts `pact.HasConfig` from Task 1, and the resolver needs `golem15.seo.site_url`.
- **Fix:** `config/config.yaml` (site_url, unprefixed_locale) was committed with the tracer. Task 2 added no further key.
- **Commit:** fb9590f
**2. [Rule 1 - Bug] Boot no longer requires a database**
- **Issue:** `classes.NewService` refused to boot without a published `*gorm.DB`. The generated main publishes none for console commands, so every command of an app with the plugin would fail.
- **Fix:** the service looks the database up when it is used.
- **Commit:** e8a6b9f
**3. [Rule 3 - Blocking] Phrasebook pipes**
- **Issue:** the phrasebook parses any `|` as plural forms. `settings.title_template_comment` ("Default: %title% | %site%") therefore failed the boot.
- **Fix:** the copy names the default in words ("the page title, a vertical bar and the site name"). This is the only UI-SPEC copy change.
- **Commit:** ce60c8a
**4. [Rule 3 - Blocking] Validation messages with parameters**
- **Issue:** cabana localizes `ValidationError` details without parameters such as `:placeholder`.
- **Fix:** the settings and rules checks run in `FormBeforeCreate/Update` and translate with params there (`models.Problem`). Extension refusals translate themselves.
- **Commit:** ce60c8a, 30edb8a
**5. [Rule 3 - Blocking] Owner-form widget fill**
- **Issue:** cabana lets an owner form's widget fill built-in scalar fields only. The URL rule preview therefore cannot fill `title` and `description`, which are seotext fields.
- **Fix:** the rule form's `_seo_snippet` has no fill. Its context answers the saved rule's title and description, so the preview shows the saved rule; `savedNote` already says so.
- **Not affected:** the SEO tab extension keeps `fill: [seo_title, seo_description, seo_keyphrase]`.
- **Commit:** 93ddbcc
**6. [Rule 2 - Missing critical] Upload permission (T-32-12/T-32-13)**
- **Issue:** cabana's field upload route does not apply extension visibility. An administrator without golem15.seo.manage could store files through the SEO tab's image field.
- **Fix:** the `seoimage` Upload requires golem15.seo.manage. TestSEOTab covers it.
- **Commit:** 93ddbcc
**7. [Rule 2 - Missing critical] History for rules and settings inside the save transaction**
- **Fix:** field-less self-extensions on `golem15.seo.rules` and on the settings controller record `AdminFormSave.Changes`. Rule deletes are recorded in `FormAfterDelete`.
- **Files outside the plan's Task 3 list:** `extensions.go`, `extensions_settings.go`, `controllers/rules.go`, `classes/type_settings.go`.
- **Commit:** e8a6b9f
**8. [Rule 2 - Missing critical] Effective-value cache on admin saves**
- **Issue:** duplicate checks read `effective_title` and `effective_description`, which only the job filled.
- **Fix:** `SaveRecord` refreshes the cache for every entry it writes.
- **Commit:** 93ddbcc
**9. [Plan detail] Extra files**
- Filters: `controllers/rules/config_filter.yaml` and `controllers/history/config_filter.yaml` (C7/C12).
- Classes: `classes/type_settings.go` and `classes/partials.go`; the extension file `extensions_settings.go`.
- Tests: `TestRuleFormPreview` and `TestLangKeysComplete`.
- The resolver gained `ResolveRecord`, so P2 works on drafts and on records being edited.
**10. [Plan detail] Per-type labels without :type**
- fields.yaml labels take no parameters. `settings.type_inject`, `type_title_template`, `type_description_template` and `type_robots` therefore use copy without `:type`; the tab title names the type.
---
**Total deviations:** 10 (4 blocking, 1 bug, 3 missing critical, 2 plan detail). Of the 4 blocking ones, 3 are framework constraints.
**Impact on plan:** behaviour is as planned. Three framework constraints are worked around inside the plugin and are candidates for framework notes:
- phrasebook pipes;
- validation details without parameters;
- owner-form widget fill.
## Issues Encountered
- **BLOCKER, file permissions:** the executor runs as `golem` (uid 1002, groups golem and docker). Everything in summercms.go is owned by `jin:jin` with mode 775, and `golem` is not in group `jin`. Affected:
- `.git`;
- `.planning/phases/32-seo-foundation`;
- `STATE.md`, `ROADMAP.md`, `REQUIREMENTS.md`.
This SUMMARY could therefore not be written to the phase directory. STATE.md and ROADMAP.md could not be updated, and no docs commit could be made. The SUMMARY was staged in the executor scratchpad instead.
- **Pre-existing failure:** `go vet ./...` and `go test -short ./...` in summercms.go fail only on `docs/examples/blog`. `docs/examples/blog/registry.gen.go` is owned by user `jin` with mode 0600; it is dated Sep 30 and carries another session's uncommitted edit. Every other package is green, and this plan does not change the framework.
- **Element tests:** element unit tests belong to plan 32-06. A throwaway happy-dom smoke passed and was not committed. It covered the robots round-trip, the stem rule and E1/E2/E3 behaviour.
## Known Stubs
None. Redirects, robots.txt, the 404 log, MCP, undo and CSV belong to plans 32-04 and 32-05 by design. The navigation item lands on URL rules until the redirects screen exists.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: upload | ../sm-seo-plugin/classes/image.go | New file-storage path (media/seo/) through the seoimage upload route; mitigated by the permission check, content sniffing, a size cap and a random file name (T-32-12) |
## User Setup Required
None. A site sets `golem15.seo.site_url`, or relies on `app.url`.
## Next Phase Readiness
- **Plan 32-04** builds on `classes.Service`, `Resolver.Resolve`, the settings address columns, the listeners and `routes.go`.
- **Plan 32-05** builds on the history core (`Record`, `WithVia`), the rule and settings writers, and `golem15.seo.manage`.
- **Nothing pushed:** `git -C ../sm-seo-plugin status -sb` shows `## main` with no upstream, and `ls-remote origin` is empty.
## Self-Check: PASSED
- All key files exist. Commits fb9590f, ce60c8a, 30edb8a, 9f1082d, e8a6b9f, ab8905c, deebd9a, 93ddbcc and b38eb4b are present in ../sm-seo-plugin.
- `go -C ../sm-seo-plugin vet ./...`, `go test -short ./...` and `go test -count=1 ./...` (Docker) are green. The Docker run covers:
- TestSEOTabTracer, TestSEOTabPermission, TestSEOTab, TestRuleFormPreview;
- TestHistoryRecordsAdminWrite, TestEntryDeletedWithRecord, TestGenerateEntries;
- TestLangKeysComplete, TestSettingsTypeTabs, TestResolverPrecedence.
- `npm run typecheck` and `scripts/check-dist.sh` are green.
- `grep -rn sm-seo-plugin ../sm-journal-plugin --include=*.go` is empty.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-27*

View File

@@ -0,0 +1,414 @@
---
phase: 32-seo-foundation
plan: 04
type: execute
wave: 4
depends_on: ["32-03"]
files_modified:
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md
- .planning/research/STACK.md
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/listeners.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/field_types.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/models/redirect.go
- ../sm-seo-plugin/models/redirect/fields.yaml
- ../sm-seo-plugin/models/redirect/columns.yaml
- ../sm-seo-plugin/models/notfound.go
- ../sm-seo-plugin/models/notfound/fields.yaml
- ../sm-seo-plugin/models/notfound/columns.yaml
- ../sm-seo-plugin/models/robots.go
- ../sm-seo-plugin/models/robots/fields.yaml
- ../sm-seo-plugin/updates/202610270006_create_golem15_seo_redirects.go
- ../sm-seo-plugin/updates/202610270007_create_golem15_seo_notfound.go
- ../sm-seo-plugin/updates/202610270008_create_golem15_seo_robots.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/redirects.go
- ../sm-seo-plugin/controllers/redirects/config_list.yaml
- ../sm-seo-plugin/controllers/redirects/config_form.yaml
- ../sm-seo-plugin/controllers/notfound.go
- ../sm-seo-plugin/controllers/notfound/config_list.yaml
- ../sm-seo-plugin/controllers/notfound/config_form.yaml
- ../sm-seo-plugin/controllers/notfound/_hint.htm
- ../sm-seo-plugin/controllers/robots.go
- ../sm-seo-plugin/controllers/robots/config_form.yaml
- ../sm-seo-plugin/controllers/robots/_staging.htm
- ../sm-seo-plugin/controllers/robots/_robots_served.htm
- ../sm-seo-plugin/controllers/api/resolve.go
- ../sm-seo-plugin/controllers/api/notfound.go
- ../sm-seo-plugin/controllers/api/verification.go
- ../sm-seo-plugin/controllers/api/robots.go
- ../sm-seo-plugin/extension/_seo_status.htm
- ../sm-seo-plugin/classes/redirects.go
- ../sm-seo-plugin/classes/redirect_graph.go
- ../sm-seo-plugin/classes/addresses.go
- ../sm-seo-plugin/classes/normalize.go
- ../sm-seo-plugin/classes/strict.go
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/notfound.go
- ../sm-seo-plugin/classes/robots.go
- ../sm-seo-plugin/classes/bots.go
- ../sm-seo-plugin/classes/verification.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/assets/src/elements/index.ts
- ../sm-seo-plugin/assets/src/elements/labels.ts
- ../sm-seo-plugin/assets/src/elements/robotstxt.ts
- ../sm-seo-plugin/assets/src/elements/robotstester.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/redirects_test.go
- ../sm-seo-plugin/robots_test.go
- ../sm-seo-plugin/addresses_test.go
autonomous: true
requirements: [SC-3, D-16, D-17, D-18, D-19, D-20, D-20a, D-20b, D-20c, D-22, D-24, D-25, D-05, D-08, D-06, D-99, D-103, D-105, D-92, D-102]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 4
confidence: low
must_haves:
truths:
- "Changing a published record's slug or path in the admin creates a 301 from the old URL to the new one in the same transaction, flattens existing chains (A→B then B→C becomes A→C) and shows the redirects.auto_created notice; the same happens after commit for writes through Journal's API, MCP or imports (SC-3, D-18, D-92, D-102)"
- "Deleting or unpublishing a record creates no redirect; its URL answers not_found and appears in the 404 log when reported (D-18, D-20)"
- "Redirects support 301, 302 and 410, exact, wildcard and regex sources with ignore-case, trailing-slash and query ignore/pass options, evaluated exact, then patterns, then regex (D-16, D-17)"
- "A redirect loop is refused from the admin with redirects.loop on target and nothing saved; a chain is saved with the redirects.chain_notice (SC-3, D-19)"
- "Slug changed back and forth (A→B, then B→A, then A→C) ends with exactly A→C and B→C (RESEARCH Pitfall 11)"
- "GET /_seo/api/v1/resolve answers the redirect decision: action redirect with status and location, action gone with 410, normalisation and strict-URL 301s, reaching the final address in one hop; at runtime a cycle stops after 10 hops with not_found and a log line (D-20a, D-20b, D-20c, D-103)"
- "URL normalisation per site: https, www or bare host, trailing slash add/remove, optional ASCII lowercasing, double slashes and /index.html collapsed, all defaults Do not change and lowercase off (D-20b, UI-SPEC Q-7)"
- "Strict URLs: a URL whose segments (category, locale prefix) do not match an existing record 301s to the record's canonical URL; a wrong URL never answers ok (D-20c)"
- "The 404 log stores URL, hits, first seen, last hit and referer from an authenticated server-to-server report; Create redirect… (prompted 301) and Mark as gone (410, record and bulk) resolve an entry (D-20)"
- "GET /robots.txt serves the stored rules, the AI bot groups for switched-off bots and the Sitemap line(s); outside production it serves User-agent: * / Disallow: / with X-Robots-Tag noindex whatever is stored; it never answers 5xx (D-22, D-25)"
- "The robots.txt editor validates live (E4) and refuses over 500 KB; the tester (E5) answers allowed or blocked with the matching line through grobotstxt, including unsaved text and AI switches (D-22, D-99)"
- "AI bot switches cover OAI-SearchBot, Claude-SearchBot, PerplexityBot, ChatGPT-User, Claude-User, GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot and Meta-ExternalAgent, all allowed by default, with search, user-fetch and training kinds (D-25, D-105)"
- "Saving robots.txt warns when it blocks a noindex address so Googlebot cannot see the noindex (D-24)"
- "Verification files are served by GET /_seo/api/v1/verification/{name}; no Go route is registered at the site root for them (D-05, D-103)"
- "The canonical-redirected warning (status.canonical_redirected) shows in P1 when a canonical URL redirects (D-06)"
- "Redirects list: empty state redirects.list_empty; six visible columns with the Chain badge on chain rows; filters status, match, origin and chain (UI-SPEC empty, populated)"
- "404 log: empty state notfound.list_empty; sorted by hits with Resolved badges; bulk Mark as gone with a plural confirm, and one already-covered row refuses the whole selection with notfound.has_rule (UI-SPEC empty, populated, zero-one-many)"
- "A refused record action shows the danger toast with the server Message (notfound.has_rule, redirects.loop) (UI-SPEC error)"
- "All SEO lists use the existing pagination and horizontal scroll with long URLs wrapped by the existing cell rules; URLs in P5, E5 results and toasts wrap with overflow-wrap anywhere (UI-SPEC overflow, long-text)"
- "E4 with empty content shows clean, 0 KB and P4 shows only the generated lines; long lines scroll horizontally and at most 10 findings plus moreFindings show (UI-SPEC empty, overflow)"
- "E5 and robots screens outside production show stagingNote and the staging callout, and P4 shows the served and the production version (UI-SPEC partial)"
prohibitions:
- statement: "A redirect loop is never saved, from the admin, MCP or an import (D-19)"
status: resolved
verification: test
- statement: "/robots.txt never answers a 5xx status, and the non-production lock can never be turned off from the admin (D-22)"
status: resolved
verification: test
- statement: "A regex capture or a passed-through query never turns an internal redirect target into an external host"
status: resolved
verification: test
artifacts:
- path: "../sm-seo-plugin/classes/redirect_graph.go"
provides: "loop simulation, chain detection, flattening"
contains: "func "
- path: "../sm-seo-plugin/classes/addresses.go"
provides: "address-change 301 from the descriptor URL diff"
contains: "func (s *Service) AddressChanged("
- path: "../sm-seo-plugin/classes/robots.go"
provides: "served file composition, staging lock, grobotstxt tester"
contains: "grobotstxt"
- path: ".planning/notes/2026-10-27-robots-txt-grobotstxt.md"
provides: "decision note naming github.com/jimsmart/grobotstxt v1.0.3 (D-99)"
contains: "github.com/jimsmart/grobotstxt"
- path: "../sm-seo-plugin/redirects_test.go"
provides: "TestSlugChange301, TestLoopRefused (admin)"
contains: "func TestSlugChange301("
key_links:
- from: "../sm-seo-plugin/extensions.go"
to: "../sm-seo-plugin/classes/addresses.go"
via: "the SEO tab extension's Save compares Describe URLs with the entry URLs and calls AddressChanged inside the save transaction"
pattern: "AddressChanged\\("
- from: "../sm-seo-plugin/listeners.go"
to: "../sm-seo-plugin/classes/addresses.go"
via: "*pact.RecordSaved listener runs the same URL diff after commit for non-admin writers"
pattern: "\\*pact\\.RecordSaved"
- from: "../sm-seo-plugin/routes.go"
to: "../sm-seo-plugin/controllers/api/robots.go"
via: "r.Get(\"/robots.txt\", ...) throttled, outside any backend middleware"
pattern: "/robots.txt"
---
<!-- planner-discipline-allow: seo -->
<!-- planner-discipline-allow: jimsmart/grobotstxt -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan delivers redirects (including the automatic 301 on an address change), the 404 log, URL normalisation, strict URLs and robots.txt with AI bots and verification files.
<objective>
Extend `sm-seo-plugin` with D-16 to D-20c and D-22 to D-25:
- the redirect engine (301/302/410; exact, wildcard, regex; options; order), loop refusal and chain notices, chain flattening (D-16, D-17, D-19);
- the automatic 301 when a record's URL changes, from the descriptor URL diff in the SEO tab's Save (same transaction) and in a `RecordSaved` listener for Journal's API, MCP and imports (D-18, D-92, D-102);
- the 404 log with an authenticated report endpoint and one-click redirect or 410 (D-20);
- URL normalisation (D-20b) and strict URLs (D-20c) folded into the resolve answer as the redirect decision endpoint (D-20a, D-103; the Nuxt layer enforces it in Phase 33, no fourth framework seam);
- the robots.txt editor (E4), tester (E5) on `github.com/jimsmart/grobotstxt` v1.0.3 with its decision note (D-99), AI bot switches including `Claude-User` (D-25, D-105), the noindex-blocked warning (D-24), `GET /robots.txt` with the non-production lock and `X-Robots-Tag` (D-22, D-08);
- verification files through `GET /_seo/api/v1/verification/{name}` only (D-05, D-103).
Purpose: SC-3 (changing a record's slug creates a 301 and flattens existing chains; loops are refused from the admin; MCP and import refusal is plan 32-05 on the same service).
Output: commits in `../sm-seo-plugin` (main, not pushed), the decision note and STACK row in this repository as a planning-docs commit. Which plan implements each decision of the phase, and what Phase 33 takes, is in `32-DECISIONS-MAP.md`.
**Planner decisions recorded here:**
- **P-18 (one service path for redirects):** every redirect write (admin form, address change, 404 actions, and later MCP/batch/CSV) goes through `classes.Service` methods `SaveRedirect`, `AddressChanged`, `DeleteRedirect`, which run the loop simulation and flattening inside the caller's transaction and write history. The Redirects form gets its loop error and chain notice from a field-less D-91 extension the plugin registers on its own `golem15.seo.redirects` controller (Save returns `pact.AdminFormErrors{"target": ...}` or a notice on `target`), because owner controllers have no notice hook.
- **P-19 (loop detection, RESEARCH Pitfall 10):** simulate with the candidate rule in place: take the rule's target, or a probe instance of a pattern source with `*` replaced by `seoprobe`, or, for regex, a probe string built from `regexp/syntax` literals when one can be built; run the redirect stage up to 10 hops; a repeated URL refuses with the hop path, more than one hop answers the chain notice. At runtime the same 10-hop cap answers not_found and logs once per URL.
- **P-20 (address change, RESEARCH Pattern 6 and Pitfall 11):** for each locale whose Describe URL differs from the entry URL of a record that was published: delete rules whose exact source equals the new URL, create old → new (301, origin slug), re-point exact rules whose target is the old URL, drop self-loops, update the entry URL, write a revision with via auto. The notice `redirects.auto_created` goes on the owner field whose name equals a placeholder of the type's pattern that appears in `AdminFormSave.Changes` (for example `slug`), else on `seo_status`.
- **P-21 (one-hop decision):** resolve runs lock flag → normalisation (all configured rewrites at once) → redirects on the normalised path, following rules to the final target → strict URL → SEO; when normalisation changed the URL or a redirect matched, the answer's location is the final target, so a visitor makes one hop.
- **P-22 (404 report):** `POST /_seo/api/v1/notfound` with `Authorization: Bearer <golem15.seo.notfound_token>` (config; constant-time comparison; endpoint answers 404 when the token is unset), body `{url, referer}`; URL capped at 2048 bytes and referer at 1024; at most `golem15.seo.notfound_max_rows` rows (default 10000), new URLs beyond it are dropped with one warning per hour (RESEARCH A6).
- **P-23 (Sitemap lines):** one `Sitemap:` line per entry of config `golem15.seo.robots_sitemaps` (default `["/sitemap.xml"]`), absolute on the site URL; Phase 33's sitemap rewrite replaces the default with its index.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-RESEARCH.md
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
@.planning/phases/32-seo-foundation/32-03-SUMMARY.md
@CLAUDE.md
<interfaces>
From plan 32-03 (sm-seo-plugin): classes.Service (single writer, history), classes.Resolver.Resolve(ctx, rawURL) (Result with per-value sources), entries keyed by (model_type, record_id, locale) with url; settings columns url_host, url_slash, url_lowercase, verify_files; extensions.go SEO tab extension with Save(in pact.AdminFormSave) and in.Changes; listeners.go registering *pact.RecordDeleted and created *pact.RecordSaved; routes.go group /_seo/api/v1 and Buckets(); P-15 resolve JSON.
From plan 32-02: signpost Registry Describe(ctx, key, id, unprefixed) and MatchPath(ctx, path, unprefixed) → (key, *pact.ContentMatchResult{ID, Locale, Exact}); pact.RecordSaved{Controller, Model, ID, Locale, Created, Changes}.
From plan 32-01: field-less extensions with only Save are valid; AdminFormErrors → 422 field lines; Notices may name owner fields.
pact.AdminRecordAction{Name, Label, Confirm, Permissions, Danger, Prompt *AdminActionPrompt, Applies, Run}; pact.AdminBulkAction; pact.ListRowStates / RowState; analog ../sm-paymentgateway-plugin/controllers/orders.go 106-155.
grobotstxt (pkg.go.dev): NewRobotsMatcher(); (*RobotsMatcher).AgentAllowed(robotsBody, userAgent, uri string) bool; MatchingLine() int (0 when no rule matched).
Route analog: ../sm-sitemap-plugin/routes.go 43-60 (r.Get("/sitemap.xml", handler, "throttle:"+bucket)).
</interfaces>
</context>
## Execution units and resume points
Each task is a resumable unit, and Task 4 (robots.txt, AI bots, verification files and the decision note) is split into five commit groups, so the plan can run across several executor contexts without losing work:
- Every task ends with the commit groups listed below, in order: `git -c safe.directory='*' -C ../sm-seo-plugin add <paths of the group>` (paths only, never `-A` or `.`), then `commit -m "<subject>"`; G1 is a planning-docs commit in this repository. No task leaves uncommitted files for the next one, every commit keeps `go vet ./...` and `go test -short ./...` green, and each group adds the lang keys it uses to both lang files. TDD test commits may precede a group's commit.
- Resume check before each task: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'` for each plugin subject, and `git log --format=%s | grep -Fx '<subject>'` here for G1. All present: the task is done; re-run its `<verify>` and go on. Some present: continue at the first missing group. None: start the task.
- An executor whose context is getting heavy stops after a task's (or a Task 4 group's) last commit and returns its completed table (task, group, subject, SHA); the continuation starts at the next missing group.
- `admin.go` embeds admin YAML and partials by glob since plan 32-03 Task 2, so new screens need no edit there. If 32-03-SUMMARY.md records file-by-file embeds instead, add each new file to `admin.go` and stage it with its group.
| Task | Commit groups, in order | Resume point after the task |
|------|-------------------------|-----------------------------|
| 1 | `feat: address-change 301 (tracer)` | TestSlugChange301 passes |
| 2 | A `feat: redirect engine with loop refusal and chain notices`; B `feat: redirects screen`; C `feat: 404 log and report endpoint`; D `feat: address-change redirects for writes outside the admin` | the engine, both screens and the listener work |
| 3 | `feat: URL normalisation, strict URLs and the one-hop redirect decision` | resolve is the redirect decision endpoint |
| 4 | G1 (this repository) `docs(32-04): grobotstxt decision note`; G2 `feat: robots.txt served file, AI bots and the non-production lock`; G3 `feat: robots.txt editor and tester screen`; G4 `feat: verification files API`; G5 `docs: robots.txt, AI bots and verification files in README` | plan complete; write 32-04-SUMMARY.md |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an editor changes a record's slug and the old URL resolves to a 301 to the new one" — one path only</name>
<files>../sm-seo-plugin/models/redirect.go, ../sm-seo-plugin/updates/202610270006_create_golem15_seo_redirects.go, ../sm-seo-plugin/classes/redirects.go, ../sm-seo-plugin/classes/addresses.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/redirects_test.go</files>
<read_first>
- ../sm-seo-plugin/extensions.go, classes/service.go, classes/resolver.go, classes/entries.go (plan 32-03)
- ../sm-seo-plugin/fixture_test.go and seo_tab_test.go (fake content plugin and admin harness)
- summercms.go/modules/pact/record_events.go and modules/signpost/content.go
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 6, Pitfall 11)
</read_first>
<action>
Per D-18 and P-20: add the redirects table (id, source, match exact|pattern|regex, target, status 301|302|410, query ignore|pass, ignore_case bool, trailing_slash bool default true, origin manual|slug|import|mcp|notfound, note, timestamps; unique (source, match); the model and the migration register themselves through `models.Register` and `updates.Register` in their own files); `classes/redirects.go` exact-source lookup only for the tracer; `classes/addresses.go` `(*Service).AddressChanged(ctx, tx, change AddressChange) (AddressResult, error)` implementing P-20; in the SEO tab extension's Save compare `Describe` URLs (read through the transaction) with the stored entry URLs and call AddressChanged per changed locale, returning the `redirects.auto_created` notice (P-20 field choice); the resolver answers `{"action":"redirect","status":301,"location":<absolute new URL>}` for an exact redirect source. `TestSlugChange301` in `redirects_test.go`: create a published fake page /pages/a with an entry, PUT slug b through the admin API (response Meta.Notices contains the auto_created notice), then resolve /pages/a → redirect 301 to the absolute /pages/b, resolve /pages/b → ok; an earlier redirect /pages/x → /pages/a now points to /pages/b. Commit (resume unit, see Execution units): stage exactly this task's `<files>` paths in ../sm-seo-plugin and commit `feat: address-change 301 (tracer)`.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestSlugChange301' -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: TestSlugChange301" 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func (s \*Service) AddressChanged(" ../sm-seo-plugin/classes/addresses.go` prints one line
- `grep -n "AddressChanged(" ../sm-seo-plugin/extensions.go` prints at least one line
- `TestSlugChange301` asserts status 301, the absolute location and the flattened earlier redirect, and passes
</acceptance_criteria>
<done>An admin slug change creates the 301 in the save transaction, flattens the earlier redirect, tells the editor, and the resolve answer redirects the old URL.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Full redirect engine, Redirects screen with loop refusal and chain notice, 404 log, and the RecordSaved listener for non-admin writes</name>
<files>../sm-seo-plugin/classes/redirects.go, ../sm-seo-plugin/classes/redirect_graph.go, ../sm-seo-plugin/classes/addresses.go, ../sm-seo-plugin/classes/notfound.go, ../sm-seo-plugin/models/redirect.go, ../sm-seo-plugin/models/redirect/fields.yaml, ../sm-seo-plugin/models/redirect/columns.yaml, ../sm-seo-plugin/models/notfound.go, ../sm-seo-plugin/models/notfound/fields.yaml, ../sm-seo-plugin/models/notfound/columns.yaml, ../sm-seo-plugin/updates/202610270007_create_golem15_seo_notfound.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/controllers/redirects.go, ../sm-seo-plugin/controllers/redirects/config_list.yaml, ../sm-seo-plugin/controllers/redirects/config_form.yaml, ../sm-seo-plugin/controllers/notfound.go, ../sm-seo-plugin/controllers/notfound/config_list.yaml, ../sm-seo-plugin/controllers/notfound/config_form.yaml, ../sm-seo-plugin/controllers/notfound/_hint.htm, ../sm-seo-plugin/controllers/api/notfound.go, ../sm-seo-plugin/extensions.go, ../sm-seo-plugin/listeners.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/redirects_test.go</files>
<read_first>
- ../sm-paymentgateway-plugin/controllers/orders.go (record actions with Applies/Run, bulk actions, FilterOptions, ListExtendQuery)
- summercms.go/modules/pact/capabilities.go (AdminRecordAction, AdminActionPrompt 370, AdminBulkAction 287, RowState 878-906)
- summercms.go/docs/backend/admin-controllers.md (Record actions, Bulk actions)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C2, C8, C9, Copywriting: Redirects, 404 log, Destructive actions)
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pitfalls 10, 11; Code Examples: wildcard source to anchored regex; Security: open redirect, ReDoS, 404 flooding)
</read_first>
<behavior>
- Exact source /old → 301 /new; ignore_case matches /OLD; trailing_slash matches /old/; query ignore drops ?a=1, pass appends it to the target; a source containing ? matches its query exactly (Q-9)
- Wildcard /old/* → /new/* maps /old/x/y to /new/x/y; regex ^/p/(\d+)$ → /post/$1 maps /p/12 to /post/12; exact wins over pattern, pattern over regex
- 410 rule: action gone, no location; 410 with a target refused (redirects.target_gone); 301 without target refused (redirects.target_required); invalid regex refused (redirects.regex_invalid); duplicate source and match refused (redirects.source_taken); source not starting with / refused (redirects.source_invalid); regex longer than 512 characters refused
- A → B saved, then B → A refused with redirects.loop naming A → B → A; /old/* → /old/new/* refused (pattern probe); A → B with B → C existing saved with chain_notice naming the final target C
- Back and forth: A→B, B→A (as an address change), then A→C ends with exactly A→C and B→C
- A regex capture substituted into a relative target never yields an absolute URL to another host; an external target is allowed only when the target literal is absolute
- 404 report with the right token upserts url, increments hits, keeps first_seen, updates last_hit and referer; wrong or missing token → 401; unset token → 404; URL over 2048 bytes → 422
- 404 record action redirect (prompt value /new) creates a 301 with origin notfound and the row turns Resolved; gone creates a 410; bulk gone over a selection containing an already-covered URL refuses the whole selection with notfound.has_rule
- Journal-style write outside cabana (RecordSaved with a slug change and Controller "") creates the 301 after commit; RecordSaved without a URL change creates nothing; an unpublished record's change creates nothing
</behavior>
<action>
Implement D-16, D-17, D-19, D-20, D-18 for non-admin writers, per P-18, P-19, P-22 and the behaviour list. `classes/redirects.go`: compiled rule set (exact map, wildcard rules ordered by longest literal prefix then id, regex rules by id, compiled once per change with stdlib regexp; wildcard via `regexp.QuoteMeta` with `*` → `(.*)`); `classes/redirect_graph.go`: simulation, loop and chain detection, flattening. Redirects screen `golem15.seo.redirects` per C8 (list columns, filters, row state `negative` labelled `redirects.state_chain` when the target is another rule's source; form layout) and the field-less self-extension of P-18. 404 log `golem15.seo.notfound` per C9 (list by hits desc, preview with `_hint.htm`, prompted `redirect` and danger `gone` record actions, bulk `gone`, row state `disabled` labelled Resolved computed through the rule set). `controllers/api/notfound.go` + route `POST /_seo/api/v1/notfound` (throttle bucket `seo-notfound`) per P-22, config keys `golem15.seo.notfound_token` (default empty) and `golem15.seo.notfound_max_rows` (default 10000) in `config/config.yaml`. `listeners.go`: `*pact.RecordSaved` for a model with a content type re-describes the record after commit in its own transaction and calls AddressChanged per changed locale (no-op when the admin Save already updated the entry). Navigation per UI-SPEC C2: item `seo` lands on `golem15.seo.redirects`, side menu Redirects, 404 log, URL rules, robots.txt, History, Import and export (robots.txt comes in Task 4 and Import and export in plan 32-05; add each side-menu entry when its controller exists). Write the tests first (`TestRedirectEngine`, `TestLoopRefused` admin path, `TestNotFoundLog`, `TestAddressChangeFromEvent` in `redirects_test.go`).
Commits (resume units, see Execution units), staged by path in ../sm-seo-plugin, in this order:
- A `feat: redirect engine with loop refusal and chain notices`: classes/redirects.go, classes/redirect_graph.go, classes/addresses.go, models/redirect.go, redirects_test.go (TestRedirectEngine);
- B `feat: redirects screen`: models/redirect/fields.yaml, models/redirect/columns.yaml, controllers/redirects.go, controllers/redirects/config_list.yaml, controllers/redirects/config_form.yaml, controllers/admin_registry.go, extensions.go, admin_navigation.go, lang files, redirects_test.go (TestLoopRefused admin path);
- C `feat: 404 log and report endpoint`: models/notfound.go, models/notfound/fields.yaml, models/notfound/columns.yaml, the notfound migration, classes/notfound.go, controllers/notfound.go, controllers/notfound/config_list.yaml, controllers/notfound/config_form.yaml, controllers/notfound/_hint.htm, controllers/api/notfound.go, controllers/admin_registry.go, admin_navigation.go, routes.go, config/config.yaml, lang files, redirects_test.go (TestNotFoundLog);
- D `feat: address-change redirects for writes outside the admin`: listeners.go, redirects_test.go (TestAddressChangeFromEvent).
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRedirectEngine|TestLoopRefused|TestNotFoundLog|TestAddressChangeFromEvent|TestSlugChange301' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
<fails_when>a "FAIL" line, "no tests to run", or any of the five names 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "subtle.ConstantTimeCompare" ../sm-seo-plugin/controllers/api/notfound.go` prints one line
- `grep -n "\*pact.RecordSaved" ../sm-seo-plugin/listeners.go` prints one line
- `grep -rn "regexp.MustCompile(.*source\|dlclark\|regexp2" ../sm-seo-plugin --include=*.go` prints nothing (stdlib RE2 only, no panicking compile on stored input)
- `TestLoopRefused` asserts a 422 with `redirects.loop` on `target` and that the redirects table row count is unchanged
- the redirect form, list and 404 screens use only lang keys present in both lang files
</acceptance_criteria>
<done>The redirect engine, loop refusal, chain notices, the 404 log with its actions and the after-commit address-change path for non-admin writers work and are smoke-tested.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: URL normalisation, strict URLs and the one-hop redirect decision in resolve</name>
<files>../sm-seo-plugin/classes/normalize.go, ../sm-seo-plugin/classes/strict.go, ../sm-seo-plugin/classes/resolver.go, ../sm-seo-plugin/controllers/api/resolve.go, ../sm-seo-plugin/extension/_seo_status.htm, ../sm-seo-plugin/addresses_test.go</files>
<read_first>
- ../sm-seo-plugin/classes/resolver.go, classes/urls.go (plan 32-03) and classes/redirects.go (Task 2)
- summercms.go/modules/signpost/content.go (MatchPath, Exact semantics)
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-20b, D-20c) and 32-RESEARCH.md (Pitfall 13, Pitfall 15)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C11 Addresses tab, Q-7)
</read_first>
<behavior>
- With defaults (keep, keep, lowercase off): http://site/a → 301 https://site/a; //a//b → /a/b; /blog/index.html → /blog; a clean URL → ok
- url_host www: https://site.tld/a → https://www.site.tld/a; url_slash add: /a → /a/; url_slash remove: /a/ → /a (the root / is never changed)
- Lowercase on: /Blog/Post → /blog/post; /Łódź keeps its non-ASCII letters; %C5%81 stays equivalent (Pitfall 13)
- A non-normalised URL whose normalised form is a redirect source answers one 301 straight to the final target
- Strict: /wrong-category/post-slug (pattern /:category/:slug) → 301 to the post's canonical URL; /pl/<default-locale-slug> → 301 to the pl URL; a matching URL → ok
- Runtime cycle (seeded directly in the table) stops after 10 hops with not_found
- P1 shows status.canonical_redirected when the record's canonical URL is a redirect source
</behavior>
<action>
Implement D-20a, D-20b, D-20c and D-103 per P-21 and the behaviour list: `classes/normalize.go` pure `Normalize(u *url.URL, opts NormalizeOptions) (*url.URL, bool)`; `classes/strict.go` through signpost `MatchPath` and `Describe`; the resolver pipeline order of P-21 with the runtime hop cap; the resolve answer gains `redirect` and `gone` actions (status, location absolute on the site URL, never from the request Host); P1 gains the canonical-redirected callout (D-06). Tests first: `TestNormalize`, `TestStrictURL`, `TestOneHop` in `addresses_test.go`. Commit (resume unit, see Execution units): stage exactly this task's `<files>` paths in ../sm-seo-plugin and commit `feat: URL normalisation, strict URLs and the one-hop redirect decision`.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestNormalize|TestStrictURL|TestOneHop' -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 three names 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "func Normalize(" ../sm-seo-plugin/classes/normalize.go` prints one line
- `grep -n "MatchPath(" ../sm-seo-plugin/classes/strict.go` prints one line
- `TestOneHop` asserts that the answer's location equals the final target for a URL that needs normalisation and then a redirect
- `TestNormalize` includes a Polish non-ASCII case
</acceptance_criteria>
<done>The resolve answer is the redirect decision endpoint: normalisation, redirects and strict URLs reach the final address in one hop, and a wrong URL never answers ok.</done>
</task>
<task type="auto">
<name>Task 4: robots.txt editor, tester, AI bots, GET /robots.txt with the non-production lock, verification files, and the grobotstxt decision note</name>
<precondition>Plan 32-03 Task 4 answered "approved" for github.com/jimsmart/grobotstxt v1.0.3 (recorded in 32-03-SUMMARY.md)</precondition>
<files>.planning/notes/2026-10-27-robots-txt-grobotstxt.md, .planning/research/STACK.md, ../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/models/robots.go, ../sm-seo-plugin/models/robots/fields.yaml, ../sm-seo-plugin/updates/202610270008_create_golem15_seo_robots.go, ../sm-seo-plugin/controllers/robots.go, ../sm-seo-plugin/controllers/robots/config_form.yaml, ../sm-seo-plugin/controllers/robots/_staging.htm, ../sm-seo-plugin/controllers/robots/_robots_served.htm, ../sm-seo-plugin/controllers/api/robots.go, ../sm-seo-plugin/controllers/api/verification.go, ../sm-seo-plugin/classes/robots.go, ../sm-seo-plugin/classes/bots.go, ../sm-seo-plugin/classes/verification.go, ../sm-seo-plugin/classes/field_types.go, ../sm-seo-plugin/field_types.go, ../sm-seo-plugin/routes.go, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/admin_navigation.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/robotstxt.ts, ../sm-seo-plugin/assets/src/elements/robotstester.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/robots_test.go</files>
<read_first>
- .planning/notes/2026-10-20-sociallogin-goth.md (decision note format) and .planning/research/STACK.md (Supporting Libraries table, the goth row)
- ../sm-sitemap-plugin/routes.go (root route with throttle bucket)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (E4, E5, C10, P4, Copywriting: robots.txt screen, E4, E5)
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Standard Stack grobotstxt, Code Examples robots test, Anti-Patterns: 5xx on robots.txt, Go /{name} route; State of the Art: noarchive, crawl-delay)
- ../sm-seo-plugin/classes/resolver.go and routes.go
</read_first>
<action>
Per D-22, D-24, D-25, D-99, D-105, D-05, D-103, D-08 and P-23:
1. Planning docs (G1, framework repo, separate commit `docs(32-04): grobotstxt decision note` staging only the note and `.planning/research/STACK.md`): `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` (use the execution date in the file name if it differs) naming `github.com/jimsmart/grobotstxt` v1.0.3, Apache-2.0, why (Google's parser semantics: longest match, allow wins ties, `*`/`$`, `MatchingLine()`), the alternatives (temoto/robotstxt, hand-rolled RFC 9309), that it is plugin-only (the framework go.mod does not require it) and D-99; one row in `.planning/research/STACK.md` Supporting Libraries in the goth row's format.
2. `go get github.com/jimsmart/grobotstxt@v1.0.3` in ../sm-seo-plugin.
3. robots singleton table (id 1, content text, bots jsonb) and `golem15.seo.robots` (`AdminSingleton`, registered in `controllers/admin_registry.go`, with its robots.txt side-menu entry in `admin_navigation.go`) per C10: tab robots.txt (staging partial, `content` of field type `robotstxt` with Decode refusing more than 500 KB with `robots.too_large`, `tester` widget E5 filled from content and every bot switch, `served` P4), tab AI bots with one switch per bot from `classes/bots.go` in the C10 order plus `Claude-User` as a user-fetch bot operated by Anthropic (D-105) placed after ChatGPT-User; comments `bots.search_comment`, `bots.user_comment`, `bots.training_comment` with `:operator`.
4. `classes/robots.go`: `ServedFile(ctx, production bool)` composes the stored rules, a `User-agent: <token>` + `Disallow: /` group per switched-off bot, and the P-23 Sitemap lines; non-production answers `User-agent: *` / `Disallow: /`. The tester action `robots_test` composes the production file from the unsaved content and switches, runs grobotstxt `NewRobotsMatcher().AgentAllowed` and `MatchingLine`, and answers allowed/blocked, the line number and text, and whether a bot switch blocked it. On save, a notice `robots.blocks_noindex` lists noindex URLs (rules with noindex and entries whose robots contain noindex) that Googlebot is blocked from (D-24).
5. Elements E4 `golem15-seo-robotstxt` (live lint per the E4 copy: unknown directive with suggestion, rule before any User-agent, bad path, missing colon, own Sitemap line, Crawl-delay; size meter; 10 findings max) and E5 `golem15-seo-robotstester` (input row, crawler select listing Googlebot, Googlebot-Image, Bingbot, every bot token and `*`, result line, staging and unsaved notes); rebuild and commit `assets/dist`.
6. Routes: `GET /robots.txt` (throttle bucket `seo-robots`, no backend middleware) serving text/plain; on any error it serves the last good body cached in memory, else `User-agent: *` / `Disallow:` with status 200, and logs; outside production it adds `X-Robots-Tag: noindex, nofollow`. `GET /_seo/api/v1/verification/{name}` (name pattern `[A-Za-z0-9._-]+\.(html|txt|xml)`) serves the stored file body with a matching content type, 404 otherwise. Every `/_seo/api/v1` response carries `X-Robots-Tag: noindex`. No route is registered at `/{name}` (RESEARCH anti-pattern).
7. Tests `TestRobotsServed` (stored + bots + Sitemap; staging lock; DB failure still 200), `TestRobotsTester` (Google documented examples: longest match, allow wins ties, `$` anchor; a switched-off bot reported as blocked by its switch), `TestVerificationFile` in `robots_test.go`. README sections for robots.txt, AI bots, verification files and the new config keys.
8. Commits (resume units, see Execution units), staged by path in ../sm-seo-plugin after G1, in this order; a resumed run skips every group whose subject exists:
- G2 `feat: robots.txt served file, AI bots and the non-production lock` (steps 2, 4 and the robots.txt route of step 6): go.mod, go.sum, models/robots.go, the robots migration, classes/robots.go, classes/bots.go, controllers/api/robots.go, routes.go, config/config.yaml, robots_test.go (TestRobotsServed, TestRobotsTester);
- G3 `feat: robots.txt editor and tester screen` (steps 3 and 5): models/robots/fields.yaml, controllers/robots.go, controllers/robots/config_form.yaml, controllers/robots/_staging.htm, controllers/robots/_robots_served.htm, controllers/admin_registry.go, admin_navigation.go, field_types.go, classes/field_types.go, assets/src/elements/index.ts, labels.ts, robotstxt.ts, robotstester.ts, styles.css, assets/dist/seo.js, assets/dist/seo.css, lang/en/lang.yaml, lang/pl/lang.yaml;
- G4 `feat: verification files API` (the verification route of step 6): classes/verification.go, controllers/api/verification.go, routes.go, robots_test.go (TestVerificationFile);
- G5 `docs: robots.txt, AI bots and verification files in README` (step 7's README sections): README.md.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestRobotsServed|TestRobotsTester|TestVerificationFile' -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 three names missing from the "--- PASS" lines</fails_when>
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit, a TypeScript error, or "assets/dist is stale" in the output</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "github.com/jimsmart/grobotstxt v1.0.3" ../sm-seo-plugin/go.mod` prints one line and `grep -c "jimsmart/grobotstxt" go.mod` (framework) prints 0
- `grep -n "github.com/jimsmart/grobotstxt" .planning/notes/*robots-txt-grobotstxt.md .planning/research/STACK.md` prints a line in each
- `grep -n "Claude-User" ../sm-seo-plugin/classes/bots.go` prints one line (D-105)
- `grep -rn "Get(\"/{" ../sm-seo-plugin/routes.go` prints nothing (no root catch-all for verification files)
- `TestRobotsServed` includes the case where the robots row cannot be read and asserts status 200
</acceptance_criteria>
<done>robots.txt is editable with live lint and a Google-faithful tester, AI bots are switchable including Claude-User, crawlers always get a 200 robots.txt with a hard lock outside production, and verification files are available to the frontend.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| anonymous crawler → GET /robots.txt, verification API | public reads |
| frontend server → POST /_seo/api/v1/notfound | server-to-server report with a shared token |
| visitor URL → resolve redirect decision | untrusted path matched against admin-supplied patterns and regex |
| editor → redirect rules | admin-supplied sources, targets and regex |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-16 | Tampering | redirect targets (open redirect) | high | mitigate | Capture substitution only into the path of a relative target; an external host only when the rule's target literal is absolute; query pass-through appends to the query only (TestRedirectEngine case) |
| T-32-17 | Denial of service | regex sources | high | mitigate | stdlib RE2 (linear time), sources capped at 512 characters, compiled once per change, compile errors refused at save |
| T-32-18 | Denial of service | 404 report endpoint | medium | mitigate | Bearer token with constant-time comparison, disabled when unset, URL/referer caps, row cap with a rate-limited warning, throttle bucket seo-notfound |
| T-32-19 | Information disclosure | staging or demo copies indexed | high | mitigate | Environment decides the lock (Disallow: / and X-Robots-Tag), no admin setting can disable it (TestRobotsServed staging case) |
| T-32-20 | Denial of service | /robots.txt 5xx | high | mitigate | Last good body cached in memory, fallback allow-all body, always 200 (TestRobotsServed failure case) |
| T-32-21 | Tampering | verification file names | medium | mitigate | Name pattern without slashes, validated at save and in the route pattern; content served as text with an explicit content type |
| T-32-22 | Denial of service | redirect cycles at runtime | medium | mitigate | 10-hop cap answering not_found plus a log line; loops refused at save |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | grobotstxt and the element toolchain approved at plan 32-03 Task 4 (blocking-human); exact version pinned; decision note and STACK row recorded |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker.
- `npm --prefix ../sm-seo-plugin run typecheck` and `scripts/check-dist.sh` green.
- Decision note and STACK row committed in this repository as their own planning-docs commit.
- Nothing pushed.
</verification>
<success_criteria>
Changing a record's slug creates a 301 and flattens chains, from the admin and from Journal's API; loops are refused in the admin; the resolve answer gives the frontend a one-hop redirect decision; robots.txt, AI bots and verification files are managed in the CMS with the non-production lock.
</success_criteria>
## Artifacts this phase produces (plan 04)
- Tables `golem15_seo_redirects`, `golem15_seo_notfound`, `golem15_seo_robots`; models `Redirect`, `NotFound`, `Robots`
- Controllers `golem15.seo.redirects`, `golem15.seo.notfound`, `golem15.seo.robots` (AdminSingleton); record actions `redirect`, `gone`; bulk action `gone`; row states Chain and Resolved
- Field type `robotstxt`; elements `golem15-seo-robotstxt` (E4), `golem15-seo-robotstester` (E5); widget action `robots_test`; partials `robots_served`, `staging`, `hint`
- `classes`: `Service.SaveRedirect`, `Service.AddressChanged`, `Service.DeleteRedirect`, redirect rule set and graph, `Normalize`, strict-URL check, `ServedFile`, bot list, verification lookup
- Routes `GET /robots.txt`, `POST /_seo/api/v1/notfound`, `GET /_seo/api/v1/verification/{name}`; resolve actions `redirect` and `gone`; buckets `seo-robots`, `seo-notfound`
- Config keys `golem15.seo.notfound_token`, `golem15.seo.notfound_max_rows`, `golem15.seo.robots_sitemaps`
- Dependency `github.com/jimsmart/grobotstxt` v1.0.3 with `.planning/notes/2026-10-27-robots-txt-grobotstxt.md` and a STACK.md row
- Tests `TestSlugChange301`, `TestRedirectEngine`, `TestLoopRefused`, `TestNotFoundLog`, `TestAddressChangeFromEvent`, `TestNormalize`, `TestStrictURL`, `TestOneHop`, `TestRobotsServed`, `TestRobotsTester`, `TestVerificationFile`
<output>
Create `.planning/phases/32-seo-foundation/32-04-SUMMARY.md` when done with every commit SHA in both repositories, per task and commit group.
</output>

View File

@@ -0,0 +1,402 @@
---
phase: 32-seo-foundation
plan: 04
subsystem: sm-seo-plugin
tags: [seo, sm-seo-plugin, redirects, 404, robots.txt, grobotstxt, normalisation, strict-urls, custom-elements]
requires:
- phase: 32-01
provides: pact.AdminFormExtension (yaml-document form, Save notices, AdminFormErrors, field-less extensions, extension widget fills)
- phase: 32-02
provides: pact.RecordSaved with Changes, cabana.EmitRecordSaved, signpost Describe/MatchPath/ContentTypeForModel
- phase: 32-03
provides: sm-seo-plugin core (classes.Service, Resolver, entries with URL snapshots, history, listeners, routes, settings address and verification columns, element bundle)
provides:
- "golem15_seo_redirects with the redirect engine (301/302/410; exact, pattern, regex; ignore case, trailing slash, query ignore/pass), save-time loop refusal and chain notices, runtime 10-hop cap"
- "AddressChanged: the automatic 301 on an address change with chain flattening, in the SEO tab save transaction and after commit for writes outside the admin"
- "golem15.seo.redirects and golem15.seo.notfound screens; POST /_seo/api/v1/notfound with bearer token"
- "Normalize, strict URLs and the one-hop redirect decision in GET /_seo/api/v1/resolve (actions redirect and gone)"
- "golem15.seo.robots singleton with the robotstxt field type (E4) and the robots_test tester widget (E5) on grobotstxt; GET /robots.txt with AI bot groups, Sitemap lines and the non-production lock"
- "GET /_seo/api/v1/verification/{name}"
- "Decision note .planning/notes/2026-10-27-robots-txt-grobotstxt.md and a STACK.md row"
affects: [32-05, 32-06, 33, vue-seo-layer, sm-sitemap-plugin]
actuals:
tokens: 60000
tasks: 4
commits: 1
plan_head_before: 930d91a3c1df3da828c5108e984ffae700708635
plan_head_after: 99889f0fe82c5cbdda0bd1b9e4185c49db8dd9fd
tech-stack:
added:
- "github.com/jimsmart/grobotstxt v1.0.3 (sm-seo-plugin only; approved at the 32-03 package gate; decision note and STACK row)"
patterns:
- "One service path for redirect writes: SaveRedirect, AddressChanged, DeleteRedirect, ResolveNotFound run validation, the loop simulation and history inside the caller's transaction"
- "Field-less self-extension on the plugin's own controller to refuse with AdminFormErrors and answer save notices from inside the save transaction (redirects)"
- "A form extension on the plugin's own singleton to carry fields an owner-form widget may not fill (robots.txt editor and bot switches for the tester)"
- "Resolve as a pipeline: normalisation, redirects followed to the final target, strict URLs, then SEO; one hop"
key-files:
created:
- ../sm-seo-plugin/classes/redirects.go
- ../sm-seo-plugin/classes/redirect_graph.go
- ../sm-seo-plugin/classes/addresses.go
- ../sm-seo-plugin/classes/notfound.go
- ../sm-seo-plugin/classes/normalize.go
- ../sm-seo-plugin/classes/strict.go
- ../sm-seo-plugin/classes/robots.go
- ../sm-seo-plugin/classes/bots.go
- ../sm-seo-plugin/classes/verification.go
- ../sm-seo-plugin/controllers/redirects.go
- ../sm-seo-plugin/controllers/notfound.go
- ../sm-seo-plugin/controllers/robots.go
- ../sm-seo-plugin/controllers/api/notfound.go
- ../sm-seo-plugin/controllers/api/robots.go
- ../sm-seo-plugin/controllers/api/verification.go
- ../sm-seo-plugin/extensions_robots.go
- ../sm-seo-plugin/models/redirect.go
- ../sm-seo-plugin/models/notfound.go
- ../sm-seo-plugin/models/robots.go
- ../sm-seo-plugin/updates/202610270006_create_golem15_seo_redirects.go
- ../sm-seo-plugin/updates/202610270007_create_golem15_seo_notfound.go
- ../sm-seo-plugin/updates/202610270008_create_golem15_seo_robots.go
- ../sm-seo-plugin/assets/src/elements/robotstxt.ts
- ../sm-seo-plugin/assets/src/elements/robotstester.ts
- ../sm-seo-plugin/redirects_test.go
- ../sm-seo-plugin/addresses_test.go
- ../sm-seo-plugin/robots_test.go
- .planning/notes/2026-10-27-robots-txt-grobotstxt.md
modified:
- ../sm-seo-plugin/classes/resolver.go
- ../sm-seo-plugin/classes/partials.go
- ../sm-seo-plugin/classes/field_types.go
- ../sm-seo-plugin/classes/service.go
- ../sm-seo-plugin/extensions.go
- ../sm-seo-plugin/listeners.go
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/controllers/admin_registry.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/assets/dist/seo.js
- ../sm-seo-plugin/assets/dist/seo.css
- .planning/research/STACK.md
key-decisions:
- "Redirect statuses are models.RedirectStatus (an int with UnmarshalText): the admin dropdown posts \"301\" as text and lagoon.Fill cannot fill an int from a string"
- "Loop detection refuses a repeated URL, a rule that fires twice, or reaching 10 hops; pattern probes replace * with seoprobe, regex probes are built from regexp/syntax literals"
- "Exact redirects are read per request by lower(source) (functional index added to the unreleased redirects migration); pattern and regex rules are loaded each request, their expressions compiled once per source"
- "A capture is percent-encoded (?, #, \\, %, non-ASCII) before substitution; a relative target collapses leading slashes; an absolute target must keep its literal's scheme and host, else the rule does not match"
- "A redirect chain that ends on a 410 answers gone for every source in it; a 302 anywhere in a chain makes the answer 302"
- "Strict URLs compare the normalised request path with the record's normalised Describe URL in the matched locale; MatchPath gets the path without a trailing slash, so records match under url_slash add"
- "Normalisation uses the request URL's scheme and host only when the host is the site URL's host or its www twin; any other host is ignored"
- "The robots.txt editor, tester and AI bot switches are fields of the plugin's own form extension on golem15.seo.robots (an owner-form widget may fill built-in scalar fields only); the owner form keeps the staging callout and P4"
- "Bot switch labels are \"<token> (<operator>)\" and their comments carry no :operator, because fields.yaml copy takes no parameters"
- "Record action Confirm texts take no parameters and the SPA shows a bulk Confirm as is, so notfound.redirect_confirm, gone_confirm and gone_selected_confirm name \"this address\" / \"the selected addresses\" instead of :url / :count"
- "The last production robots.txt body is cached on the service and served when the store fails; without one the body is allow-all; always 200"
- "Verification files are served with nosniff and Content-Security-Policy default-src 'none'; sandbox"
patterns-established:
- "Service methods return typed refusals (ProblemsError, LoopError, NotFoundHasRuleError) that each caller maps: 422 field lines on a form, 403 toast messages on record and bulk actions"
requirements-completed: [SC-3, D-16, D-17, D-18, D-19, D-20, D-20a, D-20b, D-20c, D-22, D-24, D-25, D-05, D-08, D-06, D-99, D-103, D-105, D-92, D-102]
coverage:
- id: D1
description: "Tracer: an admin slug change creates the 301 in the save transaction, flattens an earlier redirect to the old address, answers redirects.auto_created on slug, and resolve redirects the old address to the absolute new one"
requirement: "SC-3"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestSlugChange301"
status: pass
human_judgment: false
- id: D2
description: "Redirect engine: exact, pattern and regex sources with ignore case, trailing slash and query ignore/pass, order exact > pattern > regex, 410 gone, field refusals, loop refusal (exact, pattern and regex probes), chain checks, slug back and forth ending with A→C and B→C, open-redirect safety"
requirement: "D-17"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestRedirectEngine"
status: pass
human_judgment: false
- id: D3
description: "Redirects screen: a loop is refused with redirects.loop on target and nothing saved (create and update); a chain is saved with redirects.chain_notice and marked negative in the list; target_required and regex_invalid answer 422; history rows"
requirement: "D-19"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestLoopRefused"
status: pass
human_judgment: false
- id: D4
description: "404 log: the bearer-token report endpoint (401, 404 when off, 422 over 2048 bytes), hits, first seen, last hit and referer; the prompted redirect and 410 record actions, Resolved rows, resolved hint, bulk 410 refused for a selection with a covered address, open filter"
requirement: "D-20"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestNotFoundLog"
status: pass
human_judgment: false
- id: D5
description: "Writes outside the admin: RecordSaved without a controller creates the 301 after commit (via auto); no change and unpublished records create nothing; an admin save that already applied it changes nothing more"
requirement: "D-102"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestAddressChangeFromEvent"
status: pass
human_judgment: false
- id: D6
description: "URL normalisation as a pure function: https, www/bare, double slashes, index files, trailing slash add/remove (root and files kept), ASCII-only lowercasing with Polish letters and percent-encoding kept"
requirement: "D-20b"
verification:
- kind: unit
ref: "../sm-seo-plugin/addresses_test.go#TestNormalize"
status: pass
human_judgment: false
- id: D7
description: "Strict URLs: a wrong category or another locale's slug 301s to the record's address, queries kept; only the record's address answers ok"
requirement: "D-20c"
verification:
- kind: integration
ref: "../sm-seo-plugin/addresses_test.go#TestStrictURL"
status: pass
human_judgment: false
- id: D8
description: "One-hop redirect decision: a non-normalised URL whose normal form is a redirect source (and a chain) answers one 301 to the final normalised target; a runtime cycle answers not_found; P1 shows status.canonical_redirected"
requirement: "D-20a"
verification:
- kind: integration
ref: "../sm-seo-plugin/addresses_test.go#TestOneHop"
status: pass
human_judgment: false
- id: D9
description: "GET /robots.txt: stored rules, AI bot groups and the Sitemap line in production; the lock with X-Robots-Tag outside production whatever is stored; 200 with the last good body or allow-all when the store fails"
requirement: "D-22"
verification:
- kind: integration
ref: "../sm-seo-plugin/robots_test.go#TestRobotsServed"
status: pass
human_judgment: false
- id: D10
description: "Tester on grobotstxt against Google's documented examples (longest match, Allow wins a tie, $ anchor, group choice) with the deciding line; a switched-off bot is reported as blocked by its switch"
requirement: "D-99"
verification:
- kind: integration
ref: "../sm-seo-plugin/robots_test.go#TestRobotsTester"
status: pass
human_judgment: false
- id: D11
description: "robots.txt screen through the admin API: field order, save of rules and switches with history, robots.blocks_noindex notice, 500 KB refusal, tester action on unsaved values incl. Claude-User, served-file partial"
requirement: "D-24"
verification:
- kind: integration
ref: "../sm-seo-plugin/robots_test.go#TestRobotsScreen"
status: pass
- kind: other
ref: "npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh"
status: pass
human_judgment: false
- id: D12
description: "Verification files through /_seo/api/v1/verification/{name} with content type, nosniff, sandbox CSP and noindex; unknown, malformed and root paths 404"
requirement: "D-05"
verification:
- kind: integration
ref: "../sm-seo-plugin/robots_test.go#TestVerificationFile"
status: pass
human_judgment: false
- id: D13
description: "E4 editor and E5 tester, the redirects and 404 log lists (badges, filters, prompted action, danger confirm) and the robots.txt screen look and behave as UI-SPEC C8-C10 in the real admin SPA"
verification: []
human_judgment: true
rationale: "Visual and interaction behaviour inside the SPA; only a throwaway happy-dom smoke of E4/E5 ran here, element unit tests are plan 32-06"
duration: 43min
completed: 2026-10-27
status: complete
---
# Phase 32 Plan 04: Redirects, 404 log, URL rules and robots.txt Summary
**Redirect engine with save-time loop refusal and chain notices, the automatic 301 on an address change (inside the admin save and after commit for API writes), the 404 log with a token-protected report endpoint, URL normalisation and strict URLs folded into a one-hop redirect decision in resolve, and robots.txt with a grobotstxt tester, eleven AI bot switches, a non-production lock that never answers 5xx, and verification files served through the API.**
## Performance
- **Duration:** 43 min
- **Started:** 2026-10-27T10:11:34Z
- **Completed:** 2026-10-27T10:54:32Z
- **Tasks:** 4 (Task 1 tracer, Task 2 in four commit groups, Task 3, Task 4 in five commit groups)
- **Files:** 64 files changed in ../sm-seo-plugin, 2 in this repository
## Accomplishments
- **Redirects (D-16, D-17, D-19).**
- Matching: exact, pattern and regex sources, ignore case, trailing slash, query ignore/pass, and a source with `?` that matches its query exactly (Q-9).
- Safety: stdlib RE2 regexes capped at 512 characters, and captures that cannot leave the site.
- Save-time simulation: a loop is refused and a chain is saved with a notice.
- Runtime: chains are followed to the final target, and a cycle stops after 10 hops.
- **Address changes (D-18, D-92, D-102, P-20).** `AddressChanged` runs inside the SEO tab save with the `redirects.auto_created` notice on the slug field. A `RecordSaved` listener does the same after commit for writers outside the admin. Unpublished and deleted records get no redirect.
- **Redirects screen (C8).** Six columns with the Chain badge, the status/match/origin/chain filters, and the navigation item landing here.
- **404 log (D-20, P-22, C9).**
- Report endpoint `POST /_seo/api/v1/notfound`: bearer token compared in constant time, off when unset, URL and referer caps, a row cap with an hourly warning.
- List by hits, with Resolved rows and the resolved hint.
- Actions: a prompted 301, a danger 410, and a bulk 410 that refuses the whole selection when one row is covered.
- **Redirect decision (D-20a, D-20b, D-20c, D-103, P-21).** Resolve now runs normalisation, then redirects, then strict URLs, then SEO. It answers `redirect` with an absolute `location` (one hop) or `gone`. P1 warns when a canonical URL redirects (D-06).
- **robots.txt (D-22, D-24, D-25, D-99, D-105, P-23, C10).**
- Field type `robotstxt` with element E4 (live lint, size meter).
- Tester E5 on `robots_test`. It works on unsaved text and switches.
- AI bots tab with eleven switches, including `Claude-User`.
- A `robots.blocks_noindex` save notice.
- `GET /robots.txt`: rules, bot groups and Sitemap lines. Outside production it serves the lock with `X-Robots-Tag`. It never answers 5xx.
- **Verification files (D-05, D-103).** `GET /_seo/api/v1/verification/{name}`. There is no root route.
- **Planning docs.** Decision note `2026-10-27-robots-txt-grobotstxt.md` and a STACK.md row.
## Task Commits
../sm-seo-plugin, branch main, not pushed:
1. **Task 1 (tracer):** `e31ac7c` feat: address-change 301 (tracer)
2. **Task 2:**
- A `34a65b8` feat: redirect engine with loop refusal and chain notices
- B `163b0aa` feat: redirects screen
- C `6eb6cb7` feat: 404 log and report endpoint
- D `327925c` feat: address-change redirects for writes outside the admin
3. **Task 3:** `4cb0fbf` feat: URL normalisation, strict URLs and the one-hop redirect decision
4. **Task 4:**
- G1 (this repository) `99889f0` docs(32-04): grobotstxt decision note
- G2 `3dd9362` feat: robots.txt served file, AI bots and the non-production lock
- G3 `51413fb` feat: robots.txt editor and tester screen
- G4 `ec74062` feat: verification files API
- G5 `b398161` docs: robots.txt, AI bots and verification files in README
Commit counts:
- ../sm-seo-plugin: `git rev-list --count b38eb4b..HEAD` = 10.
- This repository before this SUMMARY's commit: `git rev-list --count 930d91a..HEAD` = 1 (G1), which is the frontmatter's `commits`.
Tracer feedback gate: the run was interactive, in end-of-phase mode, with an automated-only verify. TestSlugChange301, vet and the `-short` tests re-ran green before Task 2.
## TDD Gate Compliance
Tasks 2 and 3 are marked `tdd="true"`, and `workflow.tdd_mode` is false.
- **Commits:** the plan defines one commit per resume unit, with the tests included, so there are no separate `test(...)` RED commits.
- **RED evidence:** none. The tests were written next to the implementation, not before it. Only TestAddressChangeFromEvent failed first, and that was on the test's own transaction: `EmitRecordSaved` needs `lagoon.Transaction`.
## Files Created/Modified
See `key-files` in the frontmatter.
- Classes: `classes/redirects.go`, `redirect_graph.go` and `addresses.go` hold the engine, the simulation and the address change. `normalize.go` and `strict.go` hold the redirect-decision stages, and `robots.go`, `bots.go` and `verification.go` the robots.txt side.
- Extension: `extensions_robots.go` builds the robots form extension. Its document is generated from `classes.Bots`.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Redirect status type**
- **Found during:** Task 2B
- **Issue:** the admin dropdown posts `"301"` as text, and `lagoon.Fill` cannot fill an `int` from a string.
- **Fix:** `models.RedirectStatus` (an int with `UnmarshalText`). The SPA compares option values as strings, so `show` matches.
- **Commit:** 163b0aa
**2. [Rule 3 - Blocking, framework constraint] The tester needs unsaved editor text**
- **Found during:** Task 4 G3
- **Issue:** an owner-form widget may fill built-in scalar fields only, so the tester could not fill a `robotstxt` field from the owner form.
- **Fix:** the editor, the tester and the bot switches are fields of the plugin's own form extension on `golem15.seo.robots`. An extension widget may fill its own value fields, plugin types included.
- Load and Save read and write `golem15_seo_robots` (`content`, `bots` jsonb), so the plan's table shape is kept.
- The owner `fields.yaml` holds the staging callout and P4.
- **Files outside the plan's list:** `extensions_robots.go` (new), `extensions.go`.
- **Commit:** 51413fb
**3. [Framework constraint] Copy without parameters**
- **Record action confirms:** `Confirm` is translated without parameters, so `notfound.redirect_confirm` and `notfound.gone_confirm` say "this address" instead of “:url”.
- **Bulk confirm:** the SPA shows a bulk `Confirm` as is, with no plural and no `:count`, so `notfound.gone_selected_confirm` is one plain sentence.
- **Bot switches:** `fields.yaml` comments take no parameters, so the labels are "<token> (<operator>)" and `bots.*_comment` has no `:operator`.
- **Commits:** 6eb6cb7, 51413fb
**4. [Plan detail] Lowercased-source index added to the redirects migration**
- `202610270006` (committed in Task 1, unreleased, not pushed) gained `CREATE INDEX ... (lower(source))` in group A. The resolver reads exact rules by that key.
- **Commit:** 34a65b8
**5. [Plan detail] resolver.go changed outside Task 3's commit**
- `classes/resolver.go` changed in the Task 1 and Task 2 A/B commits, so each commit compiles with the engine of that commit.
- **Commits:** e31ac7c, 34a65b8, 163b0aa
**6. [Plan detail] Extra files and keys**
- Filters: `controllers/redirects/config_filter.yaml` and `controllers/notfound/config_filter.yaml`.
- Classes:
- `classes/partials.go` gained the canonical-redirected callout. `extension/_seo_status.htm` needed no change, because callouts render generically.
- `classes/service.go` caches the last good robots.txt.
- Test: `TestRobotsScreen`, an admin-path smoke test.
- Lang keys not in the UI-SPEC:
- `redirects.target_invalid`: a target that is neither a path nor an http(s) URL;
- `robots.test_invalid` and `robots.served_production` (P4's "Production" heading);
- `robots.staging_text` reuses the UI-SPEC copy.
**7. [Plan detail] Strict URLs and trailing slashes**
- The strict check gives `MatchPath` the path without a trailing slash, so records still match when the site adds slashes.
- With the default `keep`, `/pages/a/` therefore 301s to the record's address `/pages/a` (before this plan it was `not_found`).
**8. [Plan detail] Filters versus badges**
- The 404 log's open/resolved filter runs in SQL over exact rules only. The Resolved badge and the actions use the full rule set, patterns and regex included.
- The redirects Chain badge and the chain filter compare the target with another rule's source literally.
**9. [Plan detail] README timing**
- The plan's G5 grouping moved the README updates for redirects, the 404 log and normalisation into the docs commit. They did not land in each feature commit.
---
**Total deviations:** 9. Two were blocking, and one is a framework copy constraint; the other six are plan details.
**Impact on plan:** behaviour is as planned, and copy changes are limited to the confirm texts and the bot comments.
**Framework candidates:**
- parameters in record-action and bulk confirms;
- parameters in fields.yaml comments;
- owner-form widget fill of plugin field types.
## Issues Encountered
None blocking:
- File permissions in summercms.go were fixed before this run, so this SUMMARY and the planning commit were written in place.
- summercms.go `go vet ./...` and `go test -short ./...` are green.
## Known Stubs
None.
- MCP tools, batch writes, undo and CSV import of redirects are plan 32-05; they reuse `SaveRedirect` and `AddressChanged`.
- Element unit tests are plan 32-06.
- The Nuxt-side enforcement of the redirect decision and of verification files is Phase 33 (D-103).
## User Setup Required
None. To feed the 404 log, a site sets `golem15.seo.notfound_token` and lets its frontend server report 404s; without a token the endpoint stays off.
## Next Phase Readiness
- Plan 32-05 builds on `SaveRedirect`, `CheckRedirect`, `DeleteRedirect`, `AddressChanged`, `ResolveNotFound` and the typed refusals. History is already recorded for redirect and robots writes.
- Phase 33 takes the resolve `redirect`/`gone` actions, the verification API and `robots_sitemaps`. The sitemap rewrite replaces the default.
- Nothing pushed: ../sm-seo-plugin `main` has no upstream.
## Self-Check: PASSED
- **Commits present:**
- ../sm-seo-plugin: e31ac7c, 34a65b8, 163b0aa, 6eb6cb7, 327925c, 4cb0fbf, 3dd9362, 51413fb, ec74062, b398161;
- this repository: 99889f0.
- **Key files:** all exist.
- **../sm-seo-plugin:**
- `go vet ./...`, `go test -short ./...` and `go test -count=1 ./...` (Docker) are green. The Docker run includes TestSlugChange301, TestRedirectEngine, TestLoopRefused, TestNotFoundLog, TestAddressChangeFromEvent, TestNormalize, TestStrictURL, TestOneHop, TestRobotsServed, TestRobotsTester, TestRobotsScreen, TestVerificationFile and TestLangKeysComplete.
- `npm run typecheck` and `scripts/check-dist.sh` are green.
- **summercms.go:** `go vet ./...` and `go test -short ./...` are green.
- **Acceptance greps:**
- `AddressChanged(` appears in `extensions.go` and `listeners.go`; `*pact.RecordSaved` appears once in `listeners.go`.
- `subtle.ConstantTimeCompare` appears in `controllers/api/notfound.go`.
- There is no `regexp.MustCompile(.*source`, dlclark or regexp2.
- grobotstxt v1.0.3 is in the plugin's go.mod and not in the framework's.
- `Claude-User` appears once in `classes/bots.go`.
- `routes.go` has no `Get("/{`.
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-27*

View File

@@ -0,0 +1,356 @@
---
phase: 32-seo-foundation
plan: 05
type: execute
wave: 5
depends_on: ["32-04"]
files_modified:
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/plugin.go
- ../sm-seo-plugin/admin.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/field_types.go
- ../sm-seo-plugin/controllers/api/auth.go
- ../sm-seo-plugin/controllers/api/seo.go
- ../sm-seo-plugin/controllers/api/rules.go
- ../sm-seo-plugin/controllers/api/redirects.go
- ../sm-seo-plugin/controllers/api/notfound.go
- ../sm-seo-plugin/controllers/api/robots.go
- ../sm-seo-plugin/controllers/api/settings.go
- ../sm-seo-plugin/controllers/api/history.go
- ../sm-seo-plugin/controllers/api/transfer.go
- ../sm-seo-plugin/controllers/api/batch.go
- ../sm-seo-plugin/controllers/api/issues.go
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/controllers/history.go
- ../sm-seo-plugin/controllers/history/config_form.yaml
- ../sm-seo-plugin/controllers/transfer.go
- ../sm-seo-plugin/controllers/transfer/config_form.yaml
- ../sm-seo-plugin/classes/owner.go
- ../sm-seo-plugin/classes/batch.go
- ../sm-seo-plugin/classes/undo.go
- ../sm-seo-plugin/classes/csv.go
- ../sm-seo-plugin/classes/transfer.go
- ../sm-seo-plugin/classes/issues.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/models/settings/fields.yaml
- ../sm-seo-plugin/assets/src/elements/index.ts
- ../sm-seo-plugin/assets/src/elements/labels.ts
- ../sm-seo-plugin/assets/src/elements/transfer.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/mcp_test.go
- ../sm-seo-plugin/batch_test.go
- ../sm-seo-plugin/transfer_test.go
- ../sm-demo-app/seo_mount_test.go
- ../sm-demo-app/seo_mcp_test.go
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch
- scripts/check-phase32.sh
autonomous: true
requirements: [SC-2, SC-3, SC-4, D-10, D-11, D-12, D-13, D-14, D-15, D-19, D-90, D-94, D-98]
estimate:
tokens: 180000
raw_tokens: 180000
tasks: 4
confidence: low
must_haves:
truths:
- "Every SEO operation of the admin (SEO tab, URL rules, redirects, 404 log actions, robots.txt and AI bots, settings, history and undo, import and export) has an MCP tool bound to a /_seo/api/v1 route, and a plugin test fails when an admin operation has no tool (SC-4, D-10)"
- "MCP tools run under the linked admin's permissions with the one golem15.seo.manage permission; a read grant sees only GET tools (D-10, D-15)"
- "Tools address pages by URL: a change goes into the record's SEO when a record is behind the URL, otherwise into a URL rule; the answer says where it was written, and force_rule writes a rule (D-11)"
- "A batch of changes can be previewed with dry_run, which returns the diff and the conflicts (loops, chains, noindex on a published record's URL, canonical pointing to a redirected URL) and writes nothing; applying a batch is all-or-nothing (SC-4, D-12)"
- "Redirect loops are refused from MCP (tool error) and from CSV import (refused row), as from the admin (SC-3, D-19)"
- "History records author, time, via (admin, mcp, import, auto, undo) and before/after values; one change or a whole batch can be undone from the admin and through MCP; an undo that would overwrite a newer change, create a loop or touch a deleted record is refused (SC-4, D-13)"
- "CSV export and import with a dry-run preview work in the admin (E6, 60 KB limit) and through MCP; files are UTF-8 with BOM, comma or semicolon, and exported cells starting with = + - @ tab or CR are prefixed with an apostrophe (D-13)"
- "The effective SEO of a URL is readable with the source of every value, and the issue report lists the CMS data checks: duplicate and missing titles and descriptions, conflicting rules, redirect chains, canonicals pointing to noindex or redirected URLs (D-13, D-14 CMS part)"
- "The demo mount is proven in a scratch worktree of sm-demo-app: Journal posts, categories and tags show the SEO tab and the pier catalog lists every SEO tool; the mount commit waits for the plugin tag in plan 32-06 because the demo's TestGoModRequiresTags refuses untagged plugins (SC-2, SC-4)"
- "scripts/check-phase32.sh exists with stages self-test, go, plugins, consumers, docker, admin, docs and marker, and its marker stage refuses SEO names or consuming-application names in the framework lines this phase added (D-90)"
- "Import and export screen: before a file is chosen noFile shows and Preview import is disabled; Apply is disabled with refusedBlocks when a row is refused and with nothing when the count is zero; more than 200 rows show rowCap; the preview table scrolls inside a max-height wrapper (UI-SPEC empty, error, zero-one-many, overflow)"
- "E5 and E6 actions disable their buttons and show the busy label while pending (UI-SPEC loading)"
- "Undo refusals show a danger toast with history.undo_stale, history.undo_loop or history.undo_gone; batch undo applies only to batched entries and uses the plural confirm (UI-SPEC error, zero-one-many)"
- "Stale import preview answers transfer.stale and E6 keeps the preview; unreadable files answer transfer.unreadable and missing columns transfer.missing_column (UI-SPEC error)"
prohibitions:
- statement: "A batch or an import is never applied partially; a refused change means nothing is written (D-12)"
status: resolved
verification: test
- statement: "An MCP read grant can never change SEO data, and no /_seo/api/v1 management route answers without golem15.seo.manage (D-10, D-15)"
status: resolved
verification: test
- statement: "An undo never silently overwrites a change made after the one being undone (D-13)"
status: resolved
verification: test
- statement: "Framework lines added by this phase name no SEO concept and no consuming application (D-90)"
status: resolved
verification: test
artifacts:
- path: "../sm-seo-plugin/mcp.go"
provides: "pact.HasMCP: every SEO tool, Guard backend, Permissions golem15.seo.manage"
contains: "func (p *Plugin) MCP() pact.MCP"
- path: "../sm-seo-plugin/operations.go"
provides: "AdminOperations: the admin operation → tool name list the parity test checks"
contains: "AdminOperations"
- path: "../sm-seo-plugin/classes/batch.go"
provides: "dry-run by rollback, all-or-nothing apply, conflicts"
contains: "dry"
- path: "../sm-seo-plugin/classes/undo.go"
provides: "undo of a change and of a batch with staleness, loop and gone guards"
contains: "undo_stale"
- path: "scripts/check-phase32.sh"
provides: "phase gate"
contains: "marker"
- path: ".planning/phases/32-seo-foundation/32-05-demo-mount.patch"
provides: "the demo mount, applied by plan 32-06 after the plugin tag"
contains: "golem15.seo"
key_links:
- from: "../sm-seo-plugin/mcp.go"
to: "../sm-seo-plugin/routes.go"
via: "each MCPTool Method+Path equals a registered /_seo/api/v1 route (pier refuses unbound tools at boot)"
pattern: "/_seo/api/v1/"
- from: "../sm-seo-plugin/controllers/api/batch.go"
to: "../sm-seo-plugin/classes/batch.go"
via: "POST /_seo/api/v1/batch with dry_run runs Batch.Run in lagoon.Transaction and rolls back on dry_run"
pattern: "lagoon\\.Transaction"
- from: "../sm-demo-app/seo_mcp_test.go"
to: "../sm-seo-plugin/mcp.go"
via: "TestSEOToolsCatalog compiles the pier catalog with -tags summercms_mcp and compares it with the plugin's MCP() tools"
pattern: "TestSEOToolsCatalog"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan makes the whole of SEO manageable through MCP and in bulk, with history, undo, CSV, and the phase gate.
<objective>
Add the `/_seo/api/v1` management API and pier MCP tools for every SEO admin operation (D-10, D-15), URL addressing (D-11), batch dry-run and all-or-nothing apply (D-12), the read and audit tools including the CMS-data issue report (D-13, D-14 CMS part), undo of one change or a batch, CSV import/export in the admin (E6) and through MCP (D-13), loop refusal on the MCP and import paths (D-19, SC-3); prove the demo mount in a scratch worktree of sm-demo-app (the commit follows the tags in plan 32-06); write `scripts/check-phase32.sh` with the D-90 marker stage.
Purpose: SC-4 (every SEO operation in the admin is available as an MCP tool; a batch change can be previewed with dry-run and undone from history) and the MCP/import half of SC-3. The crawler half of D-14 is Phase 33.
Output: commits in `../sm-seo-plugin` (main, not pushed), the demo test files committed in `../sm-demo-app` (they skip when the plugin is not mounted), the demo mount patch in this phase directory, and `scripts/check-phase32.sh` in this repository. Which plan implements each decision of the phase, and what Phase 33 takes, is in `32-DECISIONS-MAP.md`.
**Planner decisions recorded here:**
- **P-24 (routes and tools):** management routes in a `/_seo/api/v1` group with throttle bucket `seo-api`, each handler resolving the backend principal (Journal's `requireBackendPrincipal` pattern through `cabana.ResolveBackend`) and requiring `golem15.seo.manage` with `cabana.Allows`. Tools (pier prefixes them with `seo_`), all `Guard: "backend"`, `Permissions: ["golem15.seo.manage"]`: GET `get_url_seo` (/seo?url=), `list_rules`, `show_rule`, `list_redirects`, `show_redirect`, `list_not_found`, `show_robots`, `test_robots` (/robots/test?url=&agent=), `show_settings`, `list_history`, `show_history`, `export_csv` (/export?dataset=&delimiter=&page=), `list_issues`; writes `set_url_seo` (PUT /seo), `create_rule`, `update_rule`, `delete_rule` (destructive), `create_redirect`, `update_redirect`, `delete_redirect` (destructive), `redirect_not_found`, `gone_not_found` (destructive), `delete_not_found` (destructive), `update_robots`, `update_settings`, `undo_change`, `undo_batch`, `import_csv` (with dry_run), `apply_batch` (with dry_run). `operations.go` exports `AdminOperations` mapping each admin operation (form save, list delete, record action, bulk action, widget action, singleton save) to its tool name; `TestEveryAdminOperationHasTool` fails on a missing or unbound one. History `via = mcp` comes from `bouncer.Dispatched(ctx, "backend")`.
- **P-25 (owner resolution, D-11):** `classes/owner.go` resolves a URL through normalisation, then signpost `MatchPath`: an Exact match writes the record's entry for the matched locale, anything else (no record, or force_rule) writes an exact URL rule; the answer carries `{"written_to": "record"|"rule", "type", "id", "locale", "rule_id"}`.
- **P-26 (batch, RESEARCH Pattern 8):** `Batch.Run(ctx, changes, BatchOptions{DryRun, Via, Author})` runs every change through the Service inside one `lagoon.Transaction`, collecting per-change diff and conflicts; dry_run returns them and rolls back deliberately; any refused change (loop, invalid value) rolls back and answers 422 with per-change errors; applied changes share one `batch_id` in history. "Noindex on a page that is in a sitemap" is checked as noindex on the URL of a published content record (the records the sitemaps list); Phase 33's D-95 filter refines it.
- **P-27 (CSV and transfer):** datasets redirects, rules, records (records address pages by URL, D-11); `encoding/csv` with BOM strip/prepend and the chosen delimiter; formula-injection prefix on export; the admin import limit is 60 KB (UI-SPEC Q-8 assumption, flagged below) and export answers at most 256 KiB per action (larger exports are paged through MCP); MCP imports at most 900 KB per call. The E6 apply token is an HMAC-SHA256 (crypto/hmac, keyed by the app key) over dataset, normalised rows and a fingerprint of the touched rows' updated_at values; apply re-runs the dry run and refuses with `transfer.stale` when the fingerprint differs.
- **P-28 (demo mount sequencing):** the demo's `TestGoModRequiresTags` requires every mounted plugin at a vX.Y.Z tag (19.6-03 precedent), and tags wait for Jakub's go-ahead in plan 32-06. So this plan proves the mount in a disposable `git worktree` of sm-demo-app with a scratch go.work (absolute paths), stores the mount as `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`, and commits only the two test files, which skip with "NOT MOUNTED" when `golem15.seo` is not registered and import no SEO package. Plan 32-06 applies the patch after tagging.
**Flagged assumption (UI-SPEC UI Considerations, unresolved row):** CSV files larger than the widget action caps (64 KiB payload, 256 KiB response) are not handled in the admin; `:limit` = 60 KB is the Phase 32 import limit pending UI-SPEC Q-8 (Jakub / planner). Larger imports go through MCP in chunks.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-RESEARCH.md
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
@.planning/phases/32-seo-foundation/32-VALIDATION.md
@.planning/phases/32-seo-foundation/32-04-SUMMARY.md
@CLAUDE.md
<interfaces>
From modules/pact/mcp.go: type MCPTool struct { Name, Title, Description, Guard, Method, Path string; Input, Output any; Permissions []string; ReadOnly, Destructive, Idempotent, OpenWorld *bool }; pact.HasMCP { MCP() pact.MCP }.
From modules/pier/catalog.go 178-182: a tool's method+pattern must match a registered route or the boot fails; a `path:` input field must be a required argument (220-223).
From ../sm-journal-plugin/mcp.go 12-110 (tool declarations, input structs with json, path and jsonschema tags, destructive pointer) and controllers/api/auth.go 83-115 (requireBackendPrincipal via cabana.ResolveBackend, permission check).
From modules/bouncer/dispatch.go: Dispatched(ctx, guard) (*Dispatch, bool).
From modules/lagoon/transaction.go: Transaction(ctx, gdb, fn).
From plans 32-03/32-04: classes.Service (SaveRedirect, AddressChanged, DeleteRedirect, entry/rule/settings writes with history), classes.Resolver, Normalize, redirect graph, history revisions with batch_id/via/before/after/undone_at.
Demo: ../sm-demo-app/boot_test.go (boot helpers), mcp_e2e_test.go (//go:build summercms_mcp, pier catalog access), go_mod_test.go (TestGoModRequiresTags), summer.yaml, go.work, .gitmodules (sitemap submodule entry format), deploy/nginx/demo.summercms.io.conf (location = /sitemap.xml block ~302), DEPLOY.md, config/.
Gate analog: scripts/check-phase19.7.sh (stage layout, go test -json detectors, marker stage, NOT RUN docker rule).
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: End-to-end "an MCP client sets noindex on a URL and the resolve answer and history reflect it" — one path only</name>
<files>../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/plugin.go, ../sm-seo-plugin/controllers/api/auth.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/classes/owner.go, ../sm-seo-plugin/mcp_test.go</files>
<read_first>
- ../sm-journal-plugin/mcp.go and controllers/api/auth.go (whole files)
- summercms.go/modules/pact/mcp.go, modules/pier/catalog.go (binding rules), modules/bouncer/dispatch.go
- summercms.go/modules/cabana/dispatch_marker_test.go (how a dispatched MCP call is simulated in a handler test)
- ../sm-seo-plugin/classes/service.go, classes/resolver.go, classes/history.go
</read_first>
<action>
Per D-10, D-11, D-13, D-15 and P-24/P-25: `controllers/api/auth.go` (backend principal + `golem15.seo.manage`); `PUT /_seo/api/v1/seo` with body `{url, locale?, values{title, description, keyphrase, canonical, robots, og_title, og_description, og_image}, force_rule?}` writing through `classes/owner.go` and the Service (history via mcp when dispatched); `mcp.go` with `var _ pact.HasMCP = (*Plugin)(nil)` and the first tool `set_url_seo` (Method PUT, Path "/_seo/api/v1/seo", Guard backend, Permissions golem15.seo.manage). `TestMCPSetURLSeoTracer` in `mcp_test.go`: with the fake content plugin and an admin principal marked as dispatched for guard backend, PUT robots "noindex" for /pages/a (a record) → answer `written_to: record`; resolve /pages/a answers robots noindex and x_robots_tag noindex; the history row has via mcp; PUT for /landing (no record) → `written_to: rule` and a rule row exists; an admin without golem15.seo.manage gets 403. Commit `feat: SEO by URL over the management API and MCP (tracer)`.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestMCPSetURLSeoTracer' -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: TestMCPSetURLSeoTracer" 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "Path: *\"/_seo/api/v1/seo\"" ../sm-seo-plugin/mcp.go` prints one line
- `grep -n "bouncer.Dispatched" ../sm-seo-plugin/classes/history.go ../sm-seo-plugin/controllers/api/*.go` prints at least one line
- `TestMCPSetURLSeoTracer` covers the record, rule and 403 cases and passes
</acceptance_criteria>
<done>An MCP-dispatched call changes a URL's SEO in the right place, the resolver serves it, history records via mcp, and the permission gate holds.</done>
</task>
<task type="auto">
<name>Task 2: The full management API and one MCP tool per admin operation, effective SEO with sources, and the CMS-data issue report</name>
<files>../sm-seo-plugin/routes.go, ../sm-seo-plugin/mcp.go, ../sm-seo-plugin/operations.go, ../sm-seo-plugin/controllers/api/seo.go, ../sm-seo-plugin/controllers/api/rules.go, ../sm-seo-plugin/controllers/api/redirects.go, ../sm-seo-plugin/controllers/api/notfound.go, ../sm-seo-plugin/controllers/api/robots.go, ../sm-seo-plugin/controllers/api/settings.go, ../sm-seo-plugin/controllers/api/history.go, ../sm-seo-plugin/controllers/api/issues.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/README.md, ../sm-seo-plugin/mcp_test.go</files>
<read_first>
- ../sm-seo-plugin/mcp.go, routes.go, controllers/api/seo.go (Task 1)
- ../sm-seo-plugin/controllers/*.go (every admin controller, record/bulk/widget action of plans 32-03 and 32-04: the operation inventory)
- .planning/phases/32-seo-foundation/32-CONTEXT.md (D-10 to D-14)
- summercms.go/docs/services/mcp.md (tool declaration rules, read/write ceilings)
</read_first>
<action>
Per D-10, D-13, D-14 (CMS data checks only), D-15, D-19 and P-24: implement every route and tool listed in P-24 on the existing Service (no logic duplicated in handlers). `GET /seo?url=` answers the effective values with their sources, the owner (record type/id/locale or rule id), the redirect decision and any P1-style warnings. Writes refuse loops with 422 `{"error":"redirect_loop","path":[...]}` so the MCP tool result is an error (D-19). `GET /issues` (`classes/issues.go`) lists: missing effective titles and descriptions per locale, duplicate effective titles and descriptions across entries and rules, URL rules overriding a record, redirect chains with their final targets, canonicals pointing to a noindex or redirected URL, noindex URLs blocked by robots.txt; each issue carries url, kind, details and the tool that fixes it. `operations.go`: `AdminOperations` covering every controller, record action, bulk action, widget action and singleton save of the plugin; `TestEveryAdminOperationHasTool` (pure) checks every operation maps to a tool of `MCP()` and every tool's Method+Path is a route `Routes()` registers (use a recording `pact.Router`), and `TestMCPReadToolsAreGET` checks ReadOnly/GET consistency. README: the API and the tool list. Commit by logical change.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestIssues|TestMCPSetURLSeoTracer' -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 four names 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 in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -c "Guard: *mcpGuard\|Guard: *\"backend\"" ../sm-seo-plugin/mcp.go` is at least 29
- `grep -c "golem15.seo.manage\|mcpPermission" ../sm-seo-plugin/mcp.go` is at least 1 and every tool references it
- `TestEveryAdminOperationHasTool` fails when one tool is removed (verified once by the executor locally, then restored)
- `grep -n "\"/issues\"" ../sm-seo-plugin/routes.go` prints one line
</acceptance_criteria>
<done>Every admin operation has an MCP tool on a permission-checked JSON route, the effective SEO with sources and the CMS-data issue report are readable, and a parity test guards drift.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Batch dry-run and apply, undo of a change and of a batch, CSV import/export with the E6 Import and export screen</name>
<files>../sm-seo-plugin/classes/batch.go, ../sm-seo-plugin/classes/undo.go, ../sm-seo-plugin/classes/csv.go, ../sm-seo-plugin/classes/transfer.go, ../sm-seo-plugin/classes/history.go, ../sm-seo-plugin/controllers/api/batch.go, ../sm-seo-plugin/controllers/api/transfer.go, ../sm-seo-plugin/controllers/api/history.go, ../sm-seo-plugin/controllers/history.go, ../sm-seo-plugin/controllers/history/config_form.yaml, ../sm-seo-plugin/controllers/transfer.go, ../sm-seo-plugin/controllers/transfer/config_form.yaml, ../sm-seo-plugin/controllers/admin_registry.go, ../sm-seo-plugin/admin_navigation.go, ../sm-seo-plugin/models/settings/fields.yaml, ../sm-seo-plugin/assets/src/elements/index.ts, ../sm-seo-plugin/assets/src/elements/labels.ts, ../sm-seo-plugin/assets/src/elements/transfer.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/batch_test.go, ../sm-seo-plugin/transfer_test.go</files>
<read_first>
- .planning/phases/32-seo-foundation/32-RESEARCH.md (Pattern 8; Pitfall 14 caps; Security: CSV formula injection)
- .planning/phases/32-seo-foundation/32-UI-SPEC.md (C12 undo actions, C13 Import and export, E6 copy, Destructive actions)
- ../sm-seo-plugin/classes/service.go, classes/history.go, classes/redirect_graph.go
- summercms.go/modules/lagoon/transaction.go
</read_first>
<behavior>
- dry_run batch of 3 redirects and 1 set_seo: answers diff and conflicts and leaves every table row count and value unchanged
- batch with one loop: 422 naming the change, nothing written; batch with a chain: applied with a chain warning
- applied batch: all revisions share one batch_id and via; undo_batch restores every before value in one transaction and writes undo revisions; undo of one change restores its before value
- undo after a newer change of the same field: refused with history.undo_stale naming the author and time; undo that would recreate a loop: history.undo_loop; undo for a deleted record: history.undo_gone
- CSV export of redirects with semicolon: starts with a UTF-8 BOM, header row, a cell =cmd exported as 'cmd prefixed; import of the exported file previews all rows Unchanged
- import with a missing column: transfer.missing_column; unreadable: transfer.unreadable; a looping row: Refused with transfer.row_loop and Apply refused; a records row whose URL has no record: transfer.row_rule
- apply with a token after the data changed: transfer.stale; apply with a fresh token: one batch, via import
- admin import over 60 KB is refused by the element before calling the action; an MCP import over 900 KB is refused with a 413-style error
</behavior>
<action>
Implement D-12 and D-13 per P-26, P-27 and the behaviour list. Routes `POST /_seo/api/v1/batch` (`apply_batch`), `POST /_seo/api/v1/import` (`import_csv`), `GET /_seo/api/v1/export` (`export_csv`), `POST /_seo/api/v1/history/{id}/undo` (`undo_change`), `POST /_seo/api/v1/history/batches/{batch}/undo` (`undo_batch`). History preview gains record actions `undo` and `undo_batch` (UI-SPEC C12: non-danger confirms, refusals as 403 Message toasts, applies-conditions). `golem15.seo.transfer` (AdminSingleton on the settings row: `delimiter` dropdown defaulting to semicolon when the default locale is pl, `files` widget E6 with fill delimiter) and its widget action `seo_transfer` (ops export, preview, apply); E6 element `golem15-seo-transfer` per C13 with the E6 copy; navigation side-menu Import and export (icon download) and History entries per C2. Tests first: `TestBatch`, `TestUndo`, `TestLoopRefused` MCP and import cases (extend the admin test of plan 32-04 with `t.Run("mcp")` and `t.Run("import")`), `TestCSVTransfer`. Rebuild and commit `assets/dist`.
</action>
<verify>
<automated>go -C ../sm-seo-plugin test ./... -run 'TestBatch|TestUndo|TestLoopRefused|TestCSVTransfer' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
<fails_when>a "FAIL" line, "no tests to run", or any of TestBatch, TestUndo, TestLoopRefused, TestCSVTransfer missing from the "--- PASS" lines, or TestLoopRefused without passing "mcp" and "import" subtests</fails_when>
<automated>npm --prefix ../sm-seo-plugin run typecheck && bash ../sm-seo-plugin/scripts/check-dist.sh</automated>
<fails_when>non-zero exit, a TypeScript error, or "assets/dist is stale" in the output</fails_when>
<automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "crypto/hmac" ../sm-seo-plugin/classes/transfer.go` prints one line
- `grep -niE "bom|feff|xef" ../sm-seo-plugin/classes/csv.go` prints at least one line (BOM strip on import and prepend on export)
- `TestBatch` asserts identical row counts and values before and after a dry run
- `grep -n "undo_stale\|undo_loop\|undo_gone" ../sm-seo-plugin/classes/undo.go` prints the three refusals
- `grep -c "customElements.define" ../sm-seo-plugin/assets/src/elements/*.ts` totals 7 (E1-E7)
</acceptance_criteria>
<done>Bulk changes preview and apply atomically, every change and batch can be undone safely, and CSV import/export works in the admin and over MCP with loop refusal on every path.</done>
</task>
<task type="auto">
<name>Task 4: Prove the demo mount in a scratch worktree, commit the demo's SEO tests, and write scripts/check-phase32.sh</name>
<files>../sm-demo-app/seo_mount_test.go, ../sm-demo-app/seo_mcp_test.go, .planning/phases/32-seo-foundation/32-05-demo-mount.patch, scripts/check-phase32.sh</files>
<read_first>
- ../sm-demo-app/summer.yaml, go.work, .gitmodules, go_mod_test.go (TestGoModRequiresTags), boot_test.go, mcp_e2e_test.go, scripts/check-pins.sh, deploy/nginx/demo.summercms.io.conf (the /sitemap.xml location), DEPLOY.md, config/app.yaml, config/menu_items.yaml
- scripts/check-phase19.7.sh (stage structure, detectors, NOT RUN docker, marker stage, workspace check)
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md (the phase base SHA) and 32-02-SUMMARY.md (Journal commit SHAs)
- .planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md (P-18 local fetch of submodules) and .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md (mounting a new plugin needs tags)
</read_first>
<action>
Per P-28, D-90, SC-2, SC-4:
1. Check `git -c safe.directory='*' -C ../sm-demo-app status --short` first and leave every existing change alone (the journal and oauthserver submodule entries already show another session's drift). Do not edit, stage or commit summer.yaml, go.work, go.mod, .gitmodules, plugins.gen.go, main.go or any submodule pointer of the real demo in this plan.
2. Demo tests (committed in ../sm-demo-app): `seo_mount_test.go` `TestSEOTabMounted` boots the demo plugin set; when `golem15.seo` is not registered it calls `t.Skip("NOT MOUNTED golem15.seo")`; otherwise it asserts the form schemas of `golem15.journal.posts`, `golem15.journal.categories` and `golem15.journal.tags` contain the SEO tab last and the Journal post tab order Edit, Manage, Categories, SEO. `seo_mcp_test.go` (`//go:build summercms_mcp`) `TestSEOToolsCatalog` compiles the pier catalog and asserts it contains `seo_` + every tool name the registered `golem15.seo` plugin returns from `MCP()` (found through the party registry and `pact.HasMCP`, no import of the SEO package; same skip rule). Both compile and skip on the current demo: run `go -C ../sm-demo-app test -short -run 'TestSEOTabMounted' .` and the tagged variant, then commit `test: SEO mount and MCP catalog checks (skip until mounted)` staging only the two files.
3. Scratch proof: `git -c safe.directory='*' -C ../sm-demo-app worktree add --detach "$WT" HEAD` with `WT=$(mktemp -d)/demo`; in it add `golem15.seo` to summer.yaml after golem15.sitemap; write `$WT/config/golem15.seo.yaml` (site_url of the Nuxt origin, notfound_token read from the environment); add `location = /robots.txt` to the nginx config next to `/sitemap.xml`; add a DEPLOY.md section (robots.txt moves to Go, copy the Disallow lines the Nuxt robots config writes today into SEO › robots.txt, set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN`); write a scratch go.work outside the worktree using absolute paths: the worktree, every live `../sm-demo-app/plugins/golem15/*` checkout except journal, `../sm-journal-plugin` (plan 32-02 commits), `../sm-seo-plugin`, with the demo's replace lines and `git.golem15.com/golem15/summercms => <abs>/summercms.go`; run `go run <abs>/summercms.go/cmd/summer build` in the worktree (GOWORK set) to regenerate plugins.gen.go and main.go; run `GOWORK=<scratch> go test -run 'TestSEOTabMounted|TestBootAllPlugins' .` and `GOWORK=<scratch> go test -tags summercms_mcp -run 'TestSEOToolsCatalog' .` (both must pass, not skip). Save `git -C "$WT" diff -- summer.yaml config deploy DEPLOY.md` (plus the new config file via `git add -N`) as `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`; remove the worktree (`git worktree remove --force`).
4. `scripts/check-phase32.sh` modelled on check-phase19.7.sh: stages `self-test`, `go` (framework vet, `go test -short ./...`, required names TestFormExtensionTracer excluded as Docker, TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionUnextendedSchemaUnchanged, TestContent*), `plugins` (sm-seo-plugin and sm-journal-plugin: workspace check that the framework resolves to this checkout, vet, test -short, required names), `consumers` (sm-demo-app build and `-short` tests untagged and tagged; prints `NOT MOUNTED golem15.seo` and fails unless `PHASE32_ALLOW_UNMOUNTED=1`; a route-drift check that each Journal pattern in the demo's `config/menu_items.yaml` (journal-post, journal-category, and journal-tag when set) equals the route the demo's Nuxt app configures for the Journal layer, else the layer default in `../vue-journal-layer/app/utils/routes.ts`, because strict URLs 301 valid pages when the two drift, RESEARCH Pitfall 15), `docker` (Postgres suites by required name in the framework, both plugins and the demo; NOT RUN without Docker, a failure with `PHASE32_REQUIRE_DOCKER=1`), `admin` (FormView vitest, `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` only when admin/src changed since the phase base), `docs` (TestDocsTree, `go run ./cmd/summer docs:build --check`, the grobotstxt decision note and STACK row present, `npm --prefix ../sm-seo-plugin run typecheck` and its check-dist), `marker` (D-90: lines added since `PHASE32_BASE` — default the plan 32-01 start SHA from 32-01-SUMMARY.md — in non-test Go files, READMEs and docs under modules/ and docs/ match neither `(?i)\bseo\b` nor a consuming application name (the list check-phase19.7.sh uses); `../sm-journal-plugin` has no import of sm-seo-plugin or sm-sitemap-plugin; the framework go.mod does not require grobotstxt). `--all` runs every stage. Plan 32-06 adds the `additive` and `compat` stages. Commit `test(32-05): check-phase32 gate` and, separately as a planning-docs commit, the patch file.
</action>
<verify>
<automated>bash scripts/check-phase32.sh --self-test</automated>
<fails_when>non-zero exit, or a "FAIL" or "refuse:" line in the output</fails_when>
<automated>PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --go --plugins --consumers --docs --marker</automated>
<fails_when>non-zero exit, a "FAIL" line, or a marker line naming a file under modules/ or docs/</fails_when>
<automated>go -C ../sm-demo-app vet ./... && go -C ../sm-demo-app test -short ./...</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output (the demo stays green with the two new skipping tests)</fails_when>
<automated>test -s .planning/phases/32-seo-foundation/32-05-demo-mount.patch && grep -c "golem15.seo" .planning/phases/32-seo-foundation/32-05-demo-mount.patch</automated>
<fails_when>non-zero exit, or a count of 0 (the patch is missing or does not mount the plugin)</fails_when>
</verify>
<acceptance_criteria>
- The executor's SUMMARY quotes the scratch run's `--- PASS: TestSEOTabMounted` and `--- PASS: TestSEOToolsCatalog` lines (not SKIP)
- `git -c safe.directory='*' -C ../sm-demo-app show --stat HEAD` lists exactly seo_mount_test.go and seo_mcp_test.go
- `git -c safe.directory='*' -C ../sm-demo-app worktree list` shows no leftover scratch worktree
- `grep -n "PHASE32_BASE" scripts/check-phase32.sh` prints the default base SHA line
- `bash scripts/check-phase32.sh --marker` exits 0
</acceptance_criteria>
<done>The demo mount is proven end to end in a scratch worktree and stored as a patch for the release plan, the demo carries tests that activate once the plugin is mounted, and the phase gate exists with the D-90 marker stage.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| MCP client → pier → /_seo/api/v1 | agent-driven calls under a linked admin's identity and grant ceiling |
| admin browser → E6 import | uploaded CSV content |
| exported CSV → spreadsheet application | cells opened by an editor's spreadsheet program |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-23 | Elevation of privilege | MCP read grants and management routes | high | mitigate | Writes are non-GET tools under pier's read/write ceiling; every route checks golem15.seo.manage (TestMCPReadToolsAreGET, TestMCPSetURLSeoTracer 403 case) |
| T-32-24 | Tampering | CSV export formula injection | medium | mitigate | Cells starting with = + - @ tab or CR are prefixed with an apostrophe (TestCSVTransfer) |
| T-32-25 | Tampering | stale or forged import apply | high | mitigate | HMAC-SHA256 token keyed by the app key over dataset, rows and a data fingerprint; apply re-runs the dry run and refuses transfer.stale |
| T-32-26 | Tampering | partial batch application | high | mitigate | One lagoon.Transaction per batch; any refused change rolls back everything (TestBatch) |
| T-32-27 | Tampering | undo overwriting newer changes | high | mitigate | Staleness guard compares current values with the revision's after values; loop and gone guards (TestUndo) |
| T-32-28 | Repudiation | who changed SEO | medium | mitigate | Every revision stores author, via (mcp from bouncer.Dispatched), time and before/after; undo is itself a revision |
| T-32-29 | Denial of service | large CSV and batch payloads | medium | mitigate | 60 KB admin import, 256 KiB export per action, 900 KB MCP import, the framework body limit, throttle bucket seo-api |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new package; the element toolchain was approved at plan 32-03 Task 4 |
</threat_model>
<verification>
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` green with Docker.
- `npm --prefix ../sm-seo-plugin run typecheck` and check-dist green.
- `bash scripts/check-phase32.sh --self-test` and `PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --go --plugins --consumers --docs --marker` green.
- sm-demo-app green with the two skipping tests; its other uncommitted changes untouched.
</verification>
<success_criteria>
Every SEO admin operation is available as an MCP tool, batches are previewed with dry-run and applied atomically, every change and batch can be undone from history, loops are refused from the admin, MCP and imports, and the phase gate is in place.
</success_criteria>
## Artifacts this phase produces (plan 05)
- Routes under `/_seo/api/v1`: `/seo`, `/rules`, `/rules/{id}`, `/redirects`, `/redirects/{id}`, `/notfound`, `/notfound/{id}/redirect`, `/notfound/{id}/gone`, `/notfound/{id}`, `/robots`, `/robots/test`, `/settings`, `/history`, `/history/{id}`, `/history/{id}/undo`, `/history/batches/{batch}/undo`, `/export`, `/import`, `/batch`, `/issues`; bucket `seo-api`
- MCP tools (prefixed `seo_` by pier): `get_url_seo`, `set_url_seo`, `list_rules`, `show_rule`, `create_rule`, `update_rule`, `delete_rule`, `list_redirects`, `show_redirect`, `create_redirect`, `update_redirect`, `delete_redirect`, `list_not_found`, `redirect_not_found`, `gone_not_found`, `delete_not_found`, `show_robots`, `update_robots`, `test_robots`, `show_settings`, `update_settings`, `list_history`, `show_history`, `undo_change`, `undo_batch`, `export_csv`, `import_csv`, `apply_batch`, `list_issues`
- `operations.go` `AdminOperations`; `classes` owner resolution, `Batch`, undo, CSV, transfer token, issues
- Controller `golem15.seo.transfer` (AdminSingleton), element `golem15-seo-transfer` (E6), widget action `seo_transfer`, history record actions `undo`, `undo_batch`
- Demo tests `TestSEOTabMounted`, `TestSEOToolsCatalog`; `.planning/phases/32-seo-foundation/32-05-demo-mount.patch`
- `scripts/check-phase32.sh` (stages self-test, go, plugins, consumers, docker, admin, docs, marker; env `PHASE32_BASE`, `PHASE32_ALLOW_UNMOUNTED`, `PHASE32_REQUIRE_DOCKER`)
- Tests `TestMCPSetURLSeoTracer`, `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestIssues`, `TestBatch`, `TestUndo`, `TestCSVTransfer`, `TestLoopRefused` (mcp, import)
<output>
Create `.planning/phases/32-seo-foundation/32-05-SUMMARY.md` when done with the commit SHAs in all three repositories, the scratch-run PASS lines and the demo working-tree state found at the start.
</output>

View File

@@ -0,0 +1,399 @@
---
phase: 32-seo-foundation
plan: 05
subsystem: sm-seo-plugin
tags: [seo, sm-seo-plugin, mcp, pier, batch, undo, csv, hmac, custom-elements, sm-demo-app, phase-gate]
requires:
- phase: 32-03
provides: classes.Service, Resolver with sources, history (Record, WithVia, ViaOf with bouncer.Dispatched), golem15.seo.manage
- phase: 32-04
provides: SaveRedirect, CheckRedirect, DeleteRedirect, ResolveNotFound, LoopError/ProblemsError, robots.txt screen and tester
- phase: 19.7
provides: pier (pact.HasMCP, tool binding to routes, read/write ceiling), cabana.ResolveBackend with the dispatch marker
provides:
- "/_seo/api/v1 management API (bucket seo-api): seo, rules, redirects, notfound actions, robots and its tester, settings, history, undo, batch, export, import, issues"
- "29 MCP tools (seo_ prefix), backend guard, golem15.seo.manage; GET tools read-only"
- "AdminOperations (operations.go) and TestEveryAdminOperationHasTool / TestMCPReadToolsAreGET"
- "URL addressing (FindOwner, SetURLSEO): record behind the URL, else an exact URL rule; force_rule"
- "RunBatch: dry run by rollback, all or nothing, per-change diff and conflicts (loop refused; chain, noindex_published, canonical_redirected, canonical_noindex warnings)"
- "Undo of a change and of a batch with undo_stale, undo_loop, undo_gone; History record actions undo and undo_batch"
- "CSV export/import (redirects, rules, records by URL): BOM, , or ;, formula escaping, preview, HMAC-SHA256 apply token with a data fingerprint (transfer.stale)"
- "golem15.seo.transfer singleton with the seo_transfer widget action and element E6 golem15-seo-transfer; navigation Import and export"
- "CMS-data issue report (classes.Issues, GET /issues)"
- "scripts/check-phase32.sh (self-test, go, plugins, consumers, docker, admin, docs, marker)"
- ".planning/phases/32-seo-foundation/32-05-demo-mount.patch (applied by plan 32-06 after the tags)"
affects: [32-06, 33, sm-demo-app, vue-seo-layer]
actuals:
tokens: 80181
tasks: 4
commits: 2
plan_head_before: f96d7d4b4a91821bb184509a19fe9ff2dd51c3d5
plan_head_after: 20d02d722a5f46437c7d96c51965d41ad51ec127
tech-stack:
added: []
patterns:
- "Management route wrapper (controllers/api manage): cabana.ResolveBackend (token or MCP dispatch) + cabana.Allows(golem15.seo.manage), typed refusals mapped to 422 redirect_loop / validation_failed, 404, 409"
- "classes.WithTx puts the API/batch transaction on the context so the resolver and every service read see uncommitted batch changes"
- "Batch changes run under per-change savepoints; the diff is the history rows the change wrote under the batch id"
- "Widget-action refusals the element must show inline are answered as data {op, error, message}, not as an action error (the SPA only toasts errors)"
key-files:
created:
- ../sm-seo-plugin/mcp.go
- ../sm-seo-plugin/operations.go
- ../sm-seo-plugin/operations_test.go
- ../sm-seo-plugin/mcp_test.go
- ../sm-seo-plugin/batch_test.go
- ../sm-seo-plugin/transfer_test.go
- ../sm-seo-plugin/classes/owner.go
- ../sm-seo-plugin/classes/rules.go
- ../sm-seo-plugin/classes/manage.go
- ../sm-seo-plugin/classes/robots_write.go
- ../sm-seo-plugin/classes/settings_write.go
- ../sm-seo-plugin/classes/issues.go
- ../sm-seo-plugin/classes/batch.go
- ../sm-seo-plugin/classes/undo.go
- ../sm-seo-plugin/classes/csv.go
- ../sm-seo-plugin/classes/transfer.go
- ../sm-seo-plugin/controllers/api/auth.go
- ../sm-seo-plugin/controllers/api/seo.go
- ../sm-seo-plugin/controllers/api/rules.go
- ../sm-seo-plugin/controllers/api/redirects.go
- ../sm-seo-plugin/controllers/api/settings.go
- ../sm-seo-plugin/controllers/api/history.go
- ../sm-seo-plugin/controllers/api/issues.go
- ../sm-seo-plugin/controllers/api/batch.go
- ../sm-seo-plugin/controllers/api/transfer.go
- ../sm-seo-plugin/controllers/transfer.go
- ../sm-seo-plugin/controllers/transfer/config_form.yaml
- ../sm-seo-plugin/models/transfer.go
- ../sm-seo-plugin/models/transfer/fields.yaml
- ../sm-seo-plugin/assets/src/elements/transfer.ts
- scripts/check-phase32.sh
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch
modified:
- ../sm-seo-plugin/routes.go
- ../sm-seo-plugin/classes/service.go
- ../sm-seo-plugin/classes/history.go
- ../sm-seo-plugin/extensions_robots.go
- ../sm-seo-plugin/controllers/api/notfound.go
- ../sm-seo-plugin/controllers/api/robots.go
- ../sm-seo-plugin/controllers/history.go
- ../sm-seo-plugin/controllers/history/config_form.yaml
- ../sm-seo-plugin/controllers/admin_registry.go
- ../sm-seo-plugin/admin_navigation.go
- ../sm-seo-plugin/redirects_test.go
- ../sm-seo-plugin/lang/en/lang.yaml
- ../sm-seo-plugin/lang/pl/lang.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/README.md
key-decisions:
- "Orchestrator override: nothing is committed to sm-demo-app in this plan; the two demo tests (TestSEOTabMounted, TestSEOToolsCatalog) travel inside 32-05-demo-mount.patch with the mount, for plan 32-06"
- "Management API errors: refused writes are 422 (redirect_loop with path and message, validation_failed with translated field errors), undo refusals and an address a rule already answers are 409, a stale import 409 through the API"
- "An undo restores through the service (SaveRecord, SaveRule, SaveRedirect, SaveRobots, SaveSettings) so loops are refused and the undo is itself a revision; staleness compares the current value of every field the change wrote with its after value (JSON-normalised)"
- "The import apply token carries the gzip-compressed file, delimiter, dataset and fingerprint, signed with HMAC-SHA256 under a key derived from app.key (a per-process random key when the app has none); apply re-runs the preview and refuses when the fingerprint differs"
- "Import maps rows inside the batch transaction; the fingerprint hashes every row's address and the current state of the object it changes, so a row that was unchanged or new at preview time also makes the apply stale"
- "The exported apostrophe on formula cells is dropped again on import, so an exported file re-imports as all unchanged"
- "The transfer screen's delimiter is the settings column csv_delimiter through a separate models.Transfer view of row 1 (field csv_delimiter, widget _transfer); show/save map to show_settings/update_settings"
- "History undo_batch confirm has no :count (record-action confirms take no parameters, as in 32-04)"
- "The robots.txt admin save now runs classes.SaveRobots too (one write path for admin and API)"
patterns-established:
- "Every SEO admin operation is listed in AdminOperations with its tool; the parity test derives the required list from controllers, config_list toolbars, record/bulk/widget actions, form extensions and field-type uploads"
requirements-completed: [SC-2, SC-3, SC-4, D-10, D-11, D-12, D-13, D-14, D-15, D-19, D-90, D-94, D-98]
coverage:
- id: D1
description: "Tracer: an MCP-dispatched administrator sets noindex on /pages/a (written_to record, en), resolve answers robots and x_robots_tag noindex, history via mcp with an MCP: author; /landing gets an exact URL rule; force_rule writes a rule; no permission is 403, anonymous 401"
requirement: "D-11"
verification:
- kind: integration
ref: "../sm-seo-plugin/mcp_test.go#TestMCPSetURLSeoTracer"
status: pass
human_judgment: false
- id: D2
description: "Every admin operation maps to an MCP tool bound to a registered /_seo/api/v1 route (fails when a tool is removed, verified locally); tools are read-only exactly when GET, all backend guard + golem15.seo.manage"
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: D3
description: "Management API as an MCP client: effective SEO with sources/owner, rules CRUD, redirects (loop 422 redirect_loop with path, chain warning, origin mcp), 404 log actions (409 on a resolved address), robots.txt and tester, settings refusals, history via mcp"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-seo-plugin/mcp_test.go#TestManagementAPI"
status: pass
human_judgment: false
- id: D4
description: "CMS-data issue report: missing description, duplicate title, rule over record, redirect chain with final target, canonical redirected, noindex blocked by robots.txt, each with its fixing tool"
requirement: "D-14"
verification:
- kind: integration
ref: "../sm-seo-plugin/mcp_test.go#TestIssues"
status: pass
human_judgment: false
- id: D5
description: "Batch: dry run answers diff and conflicts (chain, noindex_published, canonical_redirected) and leaves every row identical; a loop or an invalid value refuses the whole batch with 422 naming the change; apply writes one batch id via mcp"
requirement: "D-12"
verification:
- kind: integration
ref: "../sm-seo-plugin/batch_test.go#TestBatch"
status: pass
human_judgment: false
- id: D6
description: "Undo of one change (undo revision), already_undone, undo_stale naming the author, undo_loop with the path, undo_gone for a deleted record, batch undo as a whole, History record actions undo/undo_batch with 403 refusals"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-seo-plugin/batch_test.go#TestUndo"
status: pass
human_judgment: false
- id: D7
description: "CSV: export with BOM, ; and 'escaped formula cells re-imports unchanged; missing_column, unreadable; a looping row refused and the apply 422; records rows create a URL rule without a record; admin token stale after a data change, forged token refused, fresh apply as one import batch; 60 KB admin and 900 KB MCP caps"
requirement: "D-13"
verification:
- kind: integration
ref: "../sm-seo-plugin/transfer_test.go#TestCSVTransfer"
status: pass
human_judgment: false
- id: D8
description: "Loops are refused from MCP (tool error with path, also in a batch) and from CSV import (refused row, apply refused), as from the admin"
requirement: "SC-3"
verification:
- kind: integration
ref: "../sm-seo-plugin/redirects_test.go#TestLoopRefused"
status: pass
human_judgment: false
- id: D9
description: "Demo mount proven in a scratch worktree: Journal posts, categories and tags carry the SEO tab last (posts Edit, Manage, Categories, SEO) and the pier catalog lists every seo_ tool (read grant: GET tools only)"
requirement: "SC-4"
verification:
- kind: integration
ref: "sm-demo-app scratch worktree (32-05-demo-mount.patch)#TestSEOTabMounted, TestSEOToolsCatalog, TestBootAllPlugins"
status: pass
human_judgment: false
- id: D10
description: "scripts/check-phase32.sh: self-test, go, consumers, docker, admin, docs and marker pass (unmounted allowed); plugins stage blocked only by an unreadable sibling fixture file"
requirement: "D-90"
verification:
- kind: other
ref: "bash scripts/check-phase32.sh --self-test; PHASE32_ALLOW_UNMOUNTED=1 bash scripts/check-phase32.sh --consumers --docs --marker; --docker; --admin; --go"
status: pass
human_judgment: true
rationale: "The plugins stage fails on TestNoTideJournalFixtures in sm-journal-plugin (../fonoteka.go fixture owned by jin, mode 0600, unreadable by the executor), not on phase code; a run as a user that can read it must confirm"
- id: D11
description: "E6 golem15-seo-transfer looks and behaves as UI-SPEC C13 in the real admin SPA (layout, badges, busy labels, Blob download, inline stale message)"
verification: []
human_judgment: true
rationale: "Visual and interaction behaviour in the SPA; only a throwaway happy-dom smoke ran (render, refused/nothing gating, stale message kept, pl plural), element unit tests are plan 32-06"
duration: 45min
completed: 2026-10-27
status: complete
---
# Phase 32 Plan 05: SEO management over MCP, batches, undo and CSV Summary
**Every SEO admin operation is now a permission-checked `/_seo/api/v1` route with an MCP tool (29 `seo_` tools, a parity test guarding drift). Pages are addressed by URL (record or URL rule). Batches dry-run by rollback and apply all or nothing. Any change or batch can be undone with stale/loop/gone guards. CSV import and export work in a new admin screen (E6, HMAC apply token) and over MCP, and loops are refused on every path. The demo mount is proven in a scratch worktree and stored as a patch, and the phase gate `check-phase32.sh` exists.**
## Performance
- **Duration:** 45 min
- **Started:** 2026-10-27T11:03:21Z
- **Completed:** 2026-10-27T11:48:42Z
- **Tasks:** 4
- **Files:** 49 files changed in ../sm-seo-plugin (7437 insertions), 2 files in this repository
## Accomplishments
- **Management API and tools (D-10, D-11, D-13, D-15, P-24, P-25).**
- Routes under `/_seo/api/v1` (bucket `seo-api`). Each resolves the administrator through `cabana.ResolveBackend`, so a pier dispatch runs as the linked admin. Each needs `golem15.seo.manage`.
- `AdminOperations` maps every admin operation to a tool. `TestEveryAdminOperationHasTool` derives the operations from the controllers, configs, actions, form extensions and field-type uploads. It failed when `test_robots` was renamed and when `show_history` was dropped, and was then restored.
- **URL addressing.** `PUT /seo` writes to the record behind the URL in its locale, else to an exact rule. `force_rule` always writes a rule. The answer names `written_to`, `type`, `id`, `locale` and `rule_id`.
- **Issue report (D-14 CMS part).** Missing and duplicate titles and descriptions, rules over records, chains, canonicals that point to noindex or redirected pages, and noindex pages blocked by robots.txt. Each issue names the tool that fixes it.
- **Batches (D-12, P-26).** One transaction with a savepoint per change. The diff is the history rows the batch wrote. A dry run rolls back on purpose; a refused change rolls back everything.
- **Undo (D-13).** `Undo` and `UndoBatch` restore through the service. The undo is itself recorded, and the undone rows are marked.
- **CSV (D-13, P-27).** Export and import of redirects, rules and records by URL. Formula cells are escaped on export and unescaped on import. The `golem15.seo.transfer` screen has element E6; MCP has `export_csv` and `import_csv`.
## Task Commits
../sm-seo-plugin, branch main, not pushed:
1. **Task 1 (tracer):** `cadf6ad` feat: SEO by URL over the management API and MCP (tracer)
2. **Task 2:** `aa0c3bc` feat: management API and an MCP tool per SEO admin operation
3. **Task 3:**
- `ab3b219` feat: batches with dry run, undo, and CSV import and export
- `6f69031` feat: import and export element (E6)
4. **Task 4:** this repository:
- `9247761` test(32-05): check-phase32 gate
- `20d02d7` docs(32-05): sm-demo-app mount patch for golem15.seo
sm-demo-app: no commit (orchestrator instruction).
`commits: 2` is measured in this repository with `git rev-list --count f96d7d4..HEAD` before this SUMMARY's commit. The plugin's 4 commits are counted with `git rev-list --count b398161..HEAD`.
Tracer feedback gate: end-of-phase mode with an automated-only verify. `TestMCPSetURLSeoTracer`, vet and the `-short` tests re-ran green before Task 2.
## Scratch run (sm-demo-app worktree at 8fcdbdd, mount applied)
- **Setup:**
- The scratch go.work used absolute paths: the worktree, the live `plugins/golem15/*` checkouts except journal, `../sm-journal-plugin`, `../sm-seo-plugin`, and the framework replace.
- `summer build` printed `✓ Built bin/demo` and regenerated `plugins.gen.go`.
- **Tagged run:** `go test -tags summercms_mcp -run 'TestSEOToolsCatalog|TestSEOTabMounted|TestBootAllPlugins'`:
```
--- PASS: TestBootAllPlugins (0.78s)
--- PASS: TestBootAllPlugins/realtime (0.38s)
--- PASS: TestSEOToolsCatalog (1.34s)
--- PASS: TestSEOTabMounted (0.54s)
```
- **Untagged run:** the same two passed (`--- PASS: TestSEOTabMounted (1.11s)`).
- **Unmounted:** both tests compile and skip with `NOT MOUNTED golem15.seo`, untagged and tagged.
- **Expected failure:** in the mounted scratch, `go test -short` fails only `TestGoModRequiresTags` (no tagged sm-seo-plugin require yet). This is P-28 and is resolved in plan 32-06.
- **Patch check:** `git apply --check` of the patch on a clean 8fcdbdd passed.
- **Cleanup:** the scratch worktree is removed, and `git worktree list` shows only the main checkout.
**Demo working tree found at the start:** ` M plugins/golem15/journal`, ` M plugins/golem15/oauthserver` (another session's submodule drift). It is unchanged at the end.
## TDD Gate Compliance
Task 3 is `tdd="true"`; `workflow.tdd_mode` is false.
- **RED:** `TestBatch`, `TestUndo`, `TestCSVTransfer` and the `mcp`/`import` subtests of `TestLoopRefused` were written first. All four failed with 404, because the routes did not exist yet. The evidence is in the scratchpad file `32-05-task3-red.txt`.
- **GREEN:** the implementation followed in commit `ab3b219`.
- **No separate RED commit:** a `test(...)` commit would break the plugin's green-at-every-commit rule (CLAUDE.md).
## Files Created/Modified
See `key-files` in the frontmatter.
## Decisions Made
See `key-decisions` in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Orchestrator override] Demo tests not committed to sm-demo-app**
- **What the plan said:** commit `seo_mount_test.go` and `seo_mcp_test.go` in sm-demo-app.
- **What changed:** the dispatch said not to commit to sm-demo-app, so both files are in `32-05-demo-mount.patch`.
- **Consequence:** the acceptance criterion `git show --stat HEAD` in sm-demo-app does not apply.
**2. [Rule 3 - Blocking] TestBootAllPlugins needs the plugin list**
- **Issue:** the demo's test counts its plugins exactly, so mounting the SEO plugin breaks it.
- **Fix:** the patch also adds `golem15.seo` to `wantPlugins` and `/_seo/api/v1` and `/robots.txt` to the routed prefixes.
**3. [Rule 3 - Blocking] `summer build` ignores GOWORK**
- **Issue:** `summer build` uses the app's own go.work.
- **Fix:** the scratch go.work was copied over the worktree's go.work for the build only, then restored. The patch carries no go.work.
**4. [Rule 3 - Blocking] surf `Where` applies to the last route only**
- **Issue:** `g.Where("id", …)` after a group of routes refused the boot.
- **Fix:** ids are validated in the handler (`pathID`, 404).
**5. [Rule 3 - Blocking] History recordActions needs a preview block**
- **Fix:** `preview: {}` in `controllers/history/config_form.yaml`.
**6. [Rule 1 - Bug] A semicolon in a query is dropped by Go's net/url**
- **Issue:** `delimiter=;` arrives empty.
- **Fix:** the test sends `%3B`; pier encodes query arguments itself.
**7. [Rule 2 - Missing critical] An import of a dry run with a refused row**
- **Issue:** a dry run with a refused row answered `import_refused`.
- **Fix:** it now answers the preview; only an apply is refused.
**8. [Plan detail] Field names and files**
- **Transfer screen:**
- The delimiter field is `csv_delimiter`, the column of the settings row (cabana binds scalar fields to model columns), and the widget is `_transfer`.
- The form is `models/transfer/fields.yaml` with a `models.Transfer` view of the row; `models/settings/fields.yaml` is unchanged.
- **Shared field types:** `classes.RedirectFields` and `classes.RuleFields` are shared by the API and the batch.
- **Extra class files:** `classes/manage.go`, `rules.go`, `robots_write.go` and `settings_write.go`.
- **Extra tests:** `TestManagementAPI` and `TestIssues`.
**9. [Plan detail] Acceptance greps**
- **`Path: "/_seo/api/v1/seo"` in mcp.go:** prints two lines after Task 2 (GET `get_url_seo` and PUT `set_url_seo`); Task 1 printed one.
- **`customElements.define`:** totals 1, because index.ts defines from one list, the 32-03 pattern. That list now holds the 7 elements E1–E7.
- **Guard count:** `grep -c "Guard: *mcpGuard"` is 29.
**10. [Framework constraint] Message parameters**
- **Undo batch confirm:** has no `:count` (record-action confirms take no parameters).
- **Applied toast:** `transfer.applied` is translated in the action with `Service.ChoiceCtx`.
**11. [Plan detail] E6 refusals shown inline**
- **Issue:** the SPA only toasts action errors, so the element could not show the stale message itself.
- **Fix:** stale, unreadable, missing column, too large and token refusals are answered as data `{op, error, message}`, so the preview stays on screen with the message.
---
**Total deviations:** 11.
- 1 orchestrator override;
- 4 blocking;
- 1 bug;
- 1 missing critical;
- 4 plan details or framework constraints.
**Impact on plan:** behaviour is as planned. The demo tests ship in the patch instead of a demo commit.
## Issues Encountered
- **`--plugins` gate stage fails on an environmental cause.** `TestNoTideJournalFixtures` in sm-journal-plugin opens `../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml`. That file is owned by `jin` with mode 0600, so the executor cannot read it. The plan does not touch it.
- The SEO and Journal checks of the stage pass when run by hand: vet, `-short`, and the required names `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestLangKeysComplete`, `TestNormalize`, `TestJournalMCP` and `TestMenuItemTypesPatterns`.
- **Other gate stages:**
- `--self-test`: 21 checks pass.
- `--go`, `--consumers` (`NOT MOUNTED golem15.seo` allowed; the route-drift check passes), `--docs` and `--marker`: pass (2756 added framework lines checked).
- `--docker`: 3 framework, 25 SEO and 5 Journal Postgres tests pass.
- `--admin`: 1563 vitest tests pass, plus the openapi check.
## Known Stubs
None. MCP uploads of the social image are not a tool: `set_url_seo` takes `og_image` as a URL.
## Threat Flags
| Flag | File | Description |
|------|------|-------------|
| threat_flag: endpoint | ../sm-seo-plugin/controllers/api/*.go | 24 new management routes under /_seo/api/v1. Each resolves a backend principal and requires golem15.seo.manage, with throttle bucket seo-api (T-32-23). |
| threat_flag: token | ../sm-seo-plugin/classes/transfer.go | An HMAC-signed, gzip-compressed import token round-trips through the browser. It is signed under an app.key-derived key, and a forged or edited token is refused (T-32-25). |
## User Setup Required
None. The demo's `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and the robots.txt copy are documented in the patch's DEPLOY.md section for plan 32-06.
## Next Phase Readiness
**Plan 32-06:**
- adds the element unit tests and the `additive`/`compat` gate stages;
- tags the framework, Journal and SEO plugin (with Jakub's go-ahead);
- bumps the requires;
- applies `32-05-demo-mount.patch`, adds the submodule and go.work entry, runs `summer build`, and runs `check-phase32.sh --all` without `PHASE32_ALLOW_UNMOUNTED`.
**Nothing pushed:** `../sm-seo-plugin` main has an origin remote but nothing was pushed.
## Self-Check: PASSED
- **Key files:** all 32 exist.
- **Commits present:**
- ../sm-seo-plugin: cadf6ad, aa0c3bc, ab3b219, 6f69031;
- this repository: 9247761, 20d02d7.
- **../sm-seo-plugin:** `go vet ./...`, `go test -short ./...` and `go test -count=1 ./...` (Docker) are green; `npm run typecheck` and `scripts/check-dist.sh` are green.
- **summercms.go:** `go vet ./...` and `go test -short ./...` are green (gate `--go`).
---
*Phase: 32-seo-foundation*
*Completed: 2026-10-27*

View File

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

View File

@@ -0,0 +1,418 @@
---
phase: 32-seo-foundation
plan: 06
type: execute
wave: 6
depends_on: ["32-05"]
files_modified:
- scripts/check-phase32.sh
- scripts/apisurface/main.go
- modules/cabana/form_extensions_test.go
- modules/cabana/record_events_test.go
- modules/signpost/content_test.go
- ../sm-seo-plugin/classes/resolver_test.go
- ../sm-seo-plugin/classes/redirect_graph_test.go
- ../sm-seo-plugin/classes/normalize_test.go
- ../sm-seo-plugin/classes/robots_value_test.go
- ../sm-seo-plugin/classes/robots_test.go
- ../sm-seo-plugin/classes/templates_test.go
- ../sm-seo-plugin/classes/csv_test.go
- ../sm-seo-plugin/classes/owner_test.go
- ../sm-seo-plugin/classes/issues_test.go
- ../sm-seo-plugin/tests/elements/text.test.ts
- ../sm-seo-plugin/tests/elements/robots.test.ts
- ../sm-seo-plugin/tests/elements/serp.test.ts
- ../sm-seo-plugin/tests/elements/robotstxt.test.ts
- ../sm-seo-plugin/tests/elements/transfer.test.ts
- ../sm-seo-plugin/go.mod
- ../sm-seo-plugin/go.sum
- ../sm-seo-plugin/README.md
- ../sm-journal-plugin/content_descriptors_test.go
- ../sm-journal-plugin/menu_item_types_test.go
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/go.sum
- ../sm-demo-app/summer.yaml
- ../sm-demo-app/go.work
- ../sm-demo-app/go.mod
- ../sm-demo-app/go.sum
- ../sm-demo-app/.gitmodules
- ../sm-demo-app/plugins.gen.go
- ../sm-demo-app/main.go
- ../sm-demo-app/config/golem15.seo.yaml
- ../sm-demo-app/deploy/nginx/demo.summercms.io.conf
- ../sm-demo-app/DEPLOY.md
- ../sm-demo-app/plugins/golem15/seo
- ../sm-demo-app/plugins/golem15/journal
- .planning/phases/32-seo-foundation/32-VALIDATION.md
- .planning/phases/32-seo-foundation/deferred-items.md
- .planning/phases/32-seo-foundation/32-06-release-log.md
autonomous: false
requirements: [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]
estimate:
tokens: 160000
raw_tokens: 160000
tasks: 5
confidence: low
must_haves:
truths:
- "The framework change of Phase 32 is strictly additive (D-106): the exported API surface of pact, cabana, signpost and every other framework package changed since the latest release tag keeps every element of that tag unchanged (no removed declaration, field or method; no changed signature, field type, struct tag, constant value or underlying type) and adds no method to an interface that exists at that tag; a new capability method goes on a new, separate optional interface; existing plugins compile and behave the same without edits"
- "Existing consumers build and pass their short tests against the new framework with no change on their side: sm-sitemap-plugin at HEAD, sm-journal-plugin at its commit before Phase 32, and sm-demo-app at its commit before Phase 32 with its pinned submodules; every other sibling plugin and application that requires the framework builds at its HEAD against it (D-106)"
- "The new code has unit tests in this last plan: cabana placement and boot errors, record events, signpost descriptors, the SEO plugin's resolver, redirect graph, normalisation, robots grammar and tester, templates, CSV, owner resolution and issues, Journal descriptors and the element backstops (SC-5)"
- "The framework release is the next v0.5.x patch tag, computed from git tag at release time and confirmed by Jakub at the checkpoint; no version number is fixed in advance (SC-5, Q-01, D-106)"
- "sm-seo-plugin and sm-journal-plugin require that framework tag, are tagged and pushed only after Jakub's go-ahead, and sm-demo-app mounts golem15.seo and bumps Journal in separate commits that pass TestGoModRequiresTags, TestSEOTabMounted and TestSEOToolsCatalog (SC-2, SC-4, SC-5)"
- "scripts/check-phase32.sh --all is green with the demo mounted, including the additive and compat stages, and its self-test proves that the additive stage refuses a removed declaration, a changed signature and a method added to an existing interface"
- "A resumed release run never tags, pushes or posts twice: every release step is recorded in 32-06-release-log.md as it completes"
- "The release tag gets a summercms.io changelog post the same day, written from changelog-post.py source output without planning IDs"
- statement: "E1 counter line at 320px container width: status text and count wrap onto two lines without overlap (UI-SPEC long-text backstop)"
verification: backstop
- statement: "E2 at 360px: the two segmented groups stack and no segment label is clipped in either language (UI-SPEC long-text backstop)"
verification: backstop
- statement: "E1 status thresholds: with a mocked measureText, 29/30 characters and 580/581px give short/good/long for both measures (UI-SPEC populated backstop)"
verification: backstop
- statement: "E2 value grammar: every directive combination round-trips in fixed order, all-inherit emits an empty string, and the Go Decode refuses malformed strings (UI-SPEC populated backstop)"
verification: backstop
- statement: "E3 effective title: an empty SEO title applies the type template, then the general template, to defaultTitle; truncation cuts at a word boundary within 580px (UI-SPEC populated backstop)"
verification: backstop
- statement: "E4 lint: each lint rule fires on its sample line and none fires on a clean file (UI-SPEC populated backstop)"
verification: backstop
- statement: "E6 apply gating: a preview with one refused row disables Apply and shows refusedBlocks (UI-SPEC populated backstop)"
verification: backstop
- statement: "E3 keyphrase stem matching: grzyby/grzybów match, grzyb/grzbiet do not, diacritics and case ignored, URL hyphens equal spaces (UI-SPEC populated backstop, Q-4)"
verification: backstop
prohibitions:
- statement: "No breaking change to existing framework API (D-106): no exported declaration of the latest tag is removed or changed, no method is added to an interface that exists at that tag, and no existing consumer needs an edit to build against the release"
status: resolved
verification: test
- statement: "Nothing is pushed, tagged, deployed or posted to the changelog without Jakub's go-ahead at the checkpoint, and no version number is hard-coded before that checkpoint computes it"
status: resolved
verification: judgment
- statement: "The changelog token is never printed, copied or minted"
status: resolved
verification: judgment
artifacts:
- path: "scripts/check-phase32.sh"
provides: "additive and compat stages added to the phase gate"
contains: "compat"
- path: "scripts/apisurface/main.go"
provides: "standard-library API surface printer the additive stage compares: signatures without parameter names, interface method sets, struct fields"
contains: "imethod"
- path: "modules/cabana/form_extensions_test.go"
provides: "the D-91 unit matrix"
contains: "func TestFormExtension"
- path: "../sm-seo-plugin/classes/redirect_graph_test.go"
provides: "TestRedirectGraph"
contains: "func TestRedirectGraph("
- path: "../sm-seo-plugin/tests/elements/serp.test.ts"
provides: "E3 backstops"
contains: "grzybów"
- path: ".planning/phases/32-seo-foundation/32-VALIDATION.md"
provides: "per-task verification map, nyquist_compliant true"
contains: "nyquist_compliant: true"
key_links:
- from: "scripts/check-phase32.sh"
to: "scripts/apisurface/main.go"
via: "additive stage runs go run ./scripts/apisurface on the last tag's worktree and on HEAD per package and refuses removed or changed lines and new methods on interfaces that exist at the tag"
pattern: "apisurface"
- from: "scripts/check-phase32.sh"
to: "../sm-journal-plugin"
via: "compat stage builds a detached worktree of Journal's pre-phase commit against this framework checkout through a scratch go.work"
pattern: "worktree add --detach"
- from: "../sm-demo-app/go.mod"
to: "../sm-seo-plugin"
via: "require git.golem15.com/golem15/sm-seo-plugin at its release tag; submodule plugins/golem15/seo at the same commit (scripts/check-pins.sh)"
pattern: "sm-seo-plugin v"
---
<!-- planner-discipline-allow: seo -->
## Phase Goal
**As a** site editor on any SummerCMS application, **I want to** 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.
This plan proves the phase with unit tests, proves the framework change is additive, and releases it.
<objective>
Write the phase's unit tests (project rule: unit tests are the last plan) per the 32-VALIDATION.md map, add the `additive` and `compat` stages to `scripts/check-phase32.sh` (D-106: the framework release is a patch and must be strictly additive), then, after Jakub's go-ahead, tag the framework with the next v0.5.x patch computed at release time (D-106) (Q-01: Phase 33 builds on this tag, not on a `replace`), release sm-seo-plugin and sm-journal-plugin on that tag, mount the plugin in sm-demo-app with the plan 32-05 patch, and post the changelog. The framework names no SEO concept and no consuming application (D-90); the seams D-91, D-92 and D-93 ship as published API (D-98 plan order).
Purpose: SC-5 (the framework release with the seams is tagged, and the new code has unit tests delivered in the phase's last plan), and the final evidence for SC-1 to SC-4.
Output: test files in three repositories, the gate stages, the updated VALIDATION.md, tags and pushes (after the go-ahead), demo commits, the changelog post.
**Decision coverage.** `32-DECISIONS-MAP.md` maps every decision of CONTEXT.md (D-01 to D-106, D-20a to D-20c, Q-01) to the plan and task that implements it, or to Phase 33; the boundary below is its Phase 33 column, and this plan's verification checks that the map lists every decision.
**Phase 33 boundary (Q-01).** These decisions are not implemented in Phase 32 and are explicitly deferred to Phase 33 ("SEO output, audit and content"); Phase 32 leaves room for them as noted:
- D-30, D-31, D-32, D-34 (sitemap rewrite, image/News/IndexNow, HTML sitemap) and D-95 (the sitemap filter interface sm-seo-plugin will implement, with `Requires()` on Golem15.Sitemap); D-24's sitemap consistency (noindex, canonicalised and redirected pages leave the sitemaps; the "sitemap URL blocked" warning). Room: signpost content descriptors list records for sitemaps; the plugin's batch dry-run already flags noindex on published records' URLs. The reviewed todo `sitemap-xslt-browser-deprecation.md` stays with that rewrite.
- D-33 (page registry; it registers its types through the same D-93 descriptors).
- D-40, D-41, D-42 (pagination); D-43, D-44, D-45, D-46 (JSON-LD, schema type and custom JSON-LD fields, publisher settings, validation) and D-03's organisation/person data; room: the entry's `data` jsonb and the reserved SEO tab anchors `seo_robots` and `seo_og_image`.
- D-50, D-51, D-52 (headings, including Journal's H1 conversion from D-96) and D-96's Journal schema types.
- D-55, D-56, D-57 (hreflang in head and sitemaps); D-58's Journal translated-slug fix is delivered in Phase 32 (plan 32-02).
- D-70, D-71, D-72 (SSR guarantees and sm-bm-app's noindex).
- D-80, D-81, D-82, D-83 (internal linking), D-85, D-86 (media SEO, breadcrumbs and the primary-category field).
- D-97 (the shared Nuxt SEO layer): it consumes the resolve answer of Phase 32, reads its route patterns from the CMS so strict URLs cannot drift from the frontend routes (RESEARCH Pitfall 15; Phase 32 only adds a drift check in the gate), enforces redirects, normalisation and strict URLs on frontend routes (D-20a, D-103), maps verification files at the site root, sends X-Robots-Tag on frontend pages, and marks signed-in routes noindex automatically (D-23's frontend half).
- D-14's crawler half (Phase 32 ships the CMS-data issue report) and D-25's optional llms.txt.
- Deferred ideas stay out of both phases: social card preview, per-redirect hit counters and validity dates, 307/308, e-commerce structured data.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/32-seo-foundation/32-CONTEXT.md
@.planning/phases/32-seo-foundation/32-VALIDATION.md
@.planning/phases/32-seo-foundation/32-UI-SPEC.md
@.planning/phases/32-seo-foundation/32-DECISIONS-MAP.md
@.planning/phases/32-seo-foundation/32-01-SUMMARY.md
@.planning/phases/32-seo-foundation/32-02-SUMMARY.md
@.planning/phases/32-seo-foundation/32-05-SUMMARY.md
@CLAUDE.md
@../CLAUDE.md
<interfaces>
Release facts: framework tags are vX.Y.Z on master (`git tag --sort=-v:refname`); other sessions may tag before this plan runs, so the version is computed at Task 4, never earlier (D-106: a patch tag, only the third number moves). sm-journal-plugin is on branch main; sm-seo-plugin on main with an empty origin until its first push. Demo rules: go_mod_test.go TestGoModRequiresTags (no replace, every golem15 requirement a canonical tag, every summer.yaml plugin required); scripts/check-pins.sh (submodule commits equal the required versions). Changelog: `../sm-summercmsio-app/scripts/changelog-post.py source vX.Y.Z` prints the tag message and commits since the previous tag; `publish vX.Y.Z notes.md --excerpt "..."` posts; the token is read from ~/.config/summercms/journal-token.
Pre-phase commits: the framework base SHA and the Journal SHA before plan 32-02's first commit are recorded in 32-01-SUMMARY.md and 32-02-SUMMARY.md; the demo SHA before plan 32-05's test commit is in 32-05-SUMMARY.md.
</interfaces>
</context>
## Execution units and resume points
Five tasks across three repositories plus a one-way release, so each task is a resumable unit and the plan runs in at least two executor contexts:
- Every task ends with the commits listed below, staged by path (`git add <paths>`, with `-C <repo>` and `-c safe.directory='*'` for sibling repositories; never `-A` or `.`). No task leaves uncommitted files for the next one. Defect fixes go in their own `fix(32-06): ...` (framework) or `fix: ...` (plugins) commits before the group's commit.
- Resume check before each task: `git -C <repo> log --format=%s | grep -Fx '<subject>'` for each subject of the task. All present: the task is done; re-run its `<verify>` and go on. Some present: continue at the first missing group. None: start the task.
- An executor whose context is getting heavy stops after a task's last commit and returns its completed-task table (task, repository, subject, SHA); the continuation starts at the next task. The Task 4 checkpoint is always such a boundary: Tasks 1 to 3 run in one context and Task 5 in a fresh one.
- Task 5 is resumed from `.planning/phases/32-seo-foundation/32-06-release-log.md`, never from memory (see its step 0).
| Task | Commit groups, in order (repository) | Resume point after the task |
|------|--------------------------------------|-----------------------------|
| 1 | `test(32-06): additive and compat stages in check-phase32` (this repository) | `--self-test`, `--additive` and `--compat` green |
| 2 | `test(32-06): framework unit matrices for form extensions, record events and descriptors` (this repository) | framework seams fully unit-tested |
| 3 | A `test: unit tests for the SEO classes` (../sm-seo-plugin); B `test: element backstops` (../sm-seo-plugin); C `test: content descriptors, journal-tag type and translated slugs` (../sm-journal-plugin); D `docs(32-06): complete the validation map` (this repository) | all unit tests in; VALIDATION.md complete |
| 4 | none (checkpoint); the continuation prompt carries the answer and the confirmed versions | release, release-no-apps or hold |
| 5 | one line per step in 32-06-release-log.md, then the demo commits named in step 4 | release done or hold recorded; write 32-06-SUMMARY.md |
<tasks>
<task type="tracer">
<name>Task 1: End-to-end compatibility proof: the additive and compat gate stages build existing consumers unchanged against the new framework — one path through every repository</name>
<files>scripts/check-phase32.sh, scripts/apisurface/main.go</files>
<read_first>
- scripts/check-phase32.sh (plan 32-05)
- scripts/check-phase19.7.sh (its additive check and self-test layout, and the scratch go.work technique for consumers; its line-based check misses methods added to existing interfaces and parameters added to multi-line signatures, which is why this plan compares API surfaces)
- ../sm-journal-plugin/go.work and ../sm-demo-app/go.work (replace lines to mirror in scratch go.work files)
- .planning/phases/32-seo-foundation/32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-05-SUMMARY.md (pre-phase SHAs)
</read_first>
<action>
Per D-106 (patch release, strictly additive), D-91, D-92, D-93:
1. `scripts/apisurface/main.go` (package main, standard library only: go/build, go/parser, go/ast, go/printer, go/token, sort, strings): `go run ./scripts/apisurface <package dir>` prints the exported API of the package in that directory, one element per line, sorted, each line collapsed to single spaces; it exits 2 with a message when the directory has no Go files. Files are the non-test files go/build's default context selects for the directory. Before printing, it clears the parameter and result names of every func type (functions, methods, interface methods, func-typed fields and parameters), so renamed parameters, realigned fields and edited comments print identically. Line kinds: `func F[<type params>](<param types>) <results>`; `method (<receiver type>) M(<param types>) <results>` for exported methods of exported types; `type T struct` plus `field T.Name <type> <tag>` per exported field and `embed T.<type>` per embedded field; `type T interface` plus `imethod T.M(<param types>) <results>` per method (unexported methods too: they decide who can implement it) and `iembed T.<type>` per embedded interface or type-set term; `type T <underlying>` for other defined types and `type T = <type>` for aliases (type parameters printed); `const X <type> = <value> @<index in its group>` with an omitted type and value carried from the previous spec of the group, as Go does; `var X <type>` (the declared type only, `var X` when it has none).
2. Stage `additive` (D-106): `LAST_TAG=$(git tag --sort=-v:refname | head -1)`; `git worktree add --detach "$T/base" "$LAST_TAG"`. Packages: `modules/pact`, `modules/cabana`, `modules/signpost`, plus every other directory under `modules/` that holds a non-test `.go` file changed since `LAST_TAG` (`git diff --name-only "$LAST_TAG" -- modules`, which includes uncommitted changes) and exists at the tag. For each package run the tool on `$T/base/<dir>` and on `<dir>` into `$T/api/<name>.base` and `$T/api/<name>.head` (`LC_ALL=C sort`), then refuse with:
(a) `additive: removed or changed in <pkg>: <line>` for every base line missing from head (`LC_ALL=C comm -23`): removed declarations, fields and methods, and every changed signature, field type, struct tag, constant value and underlying type, including a signature written over several lines that gains a parameter;
(b) `additive: method added to existing interface in <pkg>: <line>` for every head `imethod T.` or `iembed T.` line that is not in base while base has `type T interface`: a method added to an existing exported interface breaks every type that implements it, so a new capability method (pact `Has*` interfaces included, such as `HasAdminActionExtensions`) must go on a new, separate optional interface, which this rule allows with any methods.
Allowed, and printed as `additive: ok <pkg> (+<n> lines)`: new declarations, new interfaces, new methods on concrete types and new fields on existing structs (Go 1 compatibility with keyed literals; the compat stage proves the consumers still build). Remove the worktree on exit (trap).
3. Stage `compat`: (a) `../sm-sitemap-plugin` must have no change since the phase base (`git status --porcelain` empty and HEAD equal to the SHA recorded at stage start of the phase, or simply no commit since the base date), then `go -C ../sm-sitemap-plugin build ./... && go -C ../sm-sitemap-plugin test -short ./...`; (b) `git -C ../sm-journal-plugin worktree add --detach "$T/journal" <pre-phase Journal SHA>` and a scratch go.work with absolute paths (use the worktree; replace summercms with this checkout, sm-translate-plugin and sm-user-plugin as in Journal's go.work), then `GOWORK=... go -C "$T/journal" build ./... && go test -short ./...`; (c) `git -C ../sm-demo-app worktree add --detach "$T/demo" <pre-phase demo SHA>`, then for each gitlink under plugins/golem15 in that commit (`git -C "$T/demo" ls-tree HEAD plugins/golem15/`) `git -C ../sm-demo-app/<path> worktree add --detach "$T/sub/<name>" <pinned SHA>`, a scratch go.work using `$T/demo` and every `$T/sub/<name>` with the demo's replace lines remapped and summercms replaced by this checkout; `go build ./...`, `go build -tags summercms_mcp ./...`, `go test -short ./...` in `$T/demo`. Then (d): every other sibling directory of this checkout whose `go.mod` requires `git.golem15.com/golem15/summercms` (the other `sm-*-plugin` and `sm-*-app` repositories and Go application workspaces), except this checkout, the three above and `../sm-seo-plugin`: a detached worktree of its HEAD, detached worktrees of its gitlinked submodules at their pinned commits as in (c), a scratch go.work mirroring its own go.work (or `use .` when it has none) with absolute paths and summercms replaced by this checkout, then `go build ./...`, printing `compat: ok <repo> (build)`. When that build fails, build the same worktree against the last tag's worktree (created as in `additive` when compat runs alone): failing there too prints `compat: skipped <repo> (fails at <LAST_TAG> too)`, otherwise refuse `compat: <repo> builds at <LAST_TAG> but not at HEAD`. Every worktree is removed on exit (trap). Print `compat: ok <repo>` per consumer of (a) to (c).
4. Add both stages to `--all`, and extend `--self-test` with planted package pairs in a temporary directory (a `base/` and a `head/` Go package, run through the tool and the stage's comparison), each printing `PASS self-test additive <case>` or `refuse: self-test additive <case>`:
- refused: `removed-func` (an exported func deleted); `changed-param` (`F(int)` becomes `F(int64)`); `multiline-param-added` (a signature written over several lines gains a parameter); `interface-method-added` (`type HasThing interface { Thing() string }` gains `Other() error`, modelled on a pact capability interface); `field-type-changed`; `const-value-changed`;
- accepted: `func-added`; `struct-field-added` (the other fields realigned by gofmt); `optional-interface-added` (a new `HasOther` interface with methods); `method-added-to-struct`; `param-renamed`; `comment-edited`.
5. Run both stages now. If `additive` refuses a line that another session changed (not Phase 32), stop and report it to Jakub with the line and the commit that changed it (`git log -S`), because a patch tag cannot carry it (D-106). If it refuses a Phase 32 line, make the seam additive in its own `fix(32-06): ...` commit (for example, move a new interface method onto a new optional interface and update its callers) before continuing.
6. Commit (resume unit, see Execution units) staging only `scripts/check-phase32.sh` and `scripts/apisurface/main.go`: `test(32-06): additive and compat stages in check-phase32`.
</action>
<verify>
<automated>bash scripts/check-phase32.sh --self-test && bash scripts/check-phase32.sh --additive --compat</automated>
<fails_when>non-zero exit, an "additive: removed or changed" or "additive: method added to existing interface" line, a "refuse:" line, a missing "compat: ok" line for sm-sitemap-plugin, sm-journal-plugin or sm-demo-app, or "FAIL" in the output</fails_when>
<automated>go vet ./scripts/apisurface && go run ./scripts/apisurface modules/pact | grep -c '^type HasAdminActionExtensions interface$'</automated>
<fails_when>non-zero exit, or a count of 0 (the surface does not list pact's capability interfaces)</fails_when>
</verify>
<acceptance_criteria>
- `bash scripts/check-phase32.sh --additive --compat 2>&1 | grep -c "compat: ok"` is at least 3, with one line each for sm-sitemap-plugin, sm-journal-plugin and sm-demo-app, and every other sibling repository that requires the framework has a `compat: ok <repo> (build)` or `compat: skipped` line
- `git -c safe.directory='*' -C ../sm-journal-plugin worktree list | wc -l` and the same for sm-demo-app print 1 after the run (no leftover worktrees)
- `bash scripts/check-phase32.sh --self-test 2>&1 | grep -c "PASS self-test additive"` prints 12, including the `interface-method-added`, `changed-param` and `multiline-param-added` refusals
- `go run ./scripts/apisurface modules/pact | grep -c "^imethod HasAdminActionExtensions\."` is at least 1 (interface method sets are part of the surface)
- `go list -deps ./scripts/apisurface | grep '\.' | grep -v '/scripts/apisurface$'` prints nothing (standard library only)
</acceptance_criteria>
<done>The gate proves end to end that the framework change is additive (D-106): no removed or changed API element and no method added to an existing interface; existing consumers build and test unchanged against it.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Framework unit tests: form-extension matrix, record-event diff, content descriptors</name>
<files>modules/cabana/form_extensions_test.go, modules/cabana/record_events_test.go, modules/signpost/content_test.go</files>
<read_first>
- .planning/phases/32-seo-foundation/32-VALIDATION.md (test map SC-1 to SC-3)
- modules/cabana/form_extensions.go, form_extensions_smoke_test.go, form_extensions_postgres_test.go (plan 32-01)
- modules/cabana/record_events.go, record_events_postgres_test.go (plan 32-02)
- modules/signpost/content.go, content_test.go (plan 32-02)
</read_first>
<behavior>
- Placement table (pure, no DB): A,B + C after A; C before first; C no anchor; two tabs + new tab between; tab-less + new tab; three plugins on one anchor in Requires order; anchor on another extension's field inside and outside Requires; hide one field; hide every field of a tab; span left after left starts a new row index; an interleaved owner list canonicalised only when extended
- Boot error table: every error of the plan 32-01 behaviour list with the exact message fragments (plugin, controller, anchor)
- Record-event diff (pure): snapshot diff of scalar columns, nil to value on create, translated per-locale changes, unchanged columns omitted
- Descriptors: MatchPattern edge cases (trailing slash, encoded segments, extra and missing segments, empty segment), MatchPath ordering, duplicate Model/Controller boot errors, types without Content unchanged
</behavior>
<action>
Write the exhaustive unit matrices named in 32-VALIDATION.md (`TestFormExtension*`, `TestFormExtensionBootErrors`, `TestRecordEventDiff`, `TestContent*`) as table tests per the behaviour list (SC-1, D-91, D-92, D-93). Pure tests run under `-short`; Docker-backed cases stay in the existing `_postgres_test.go` files. Fix any defect the tests expose in the same task with its own `fix(32-06): ...` commit. Commit (resume unit, see Execution units) staging only the three test files: `test(32-06): framework unit matrices for form extensions, record events and descriptors`.
</action>
<verify>
<automated>go test -short ./modules/cabana ./modules/signpost -run 'TestFormExtension|TestRecordEventDiff|TestContent' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestFormExtensionBootErrors, TestRecordEventDiff or a TestContent test missing from the "--- PASS" lines</fails_when>
<automated>go vet ./... && go test -count=1 ./modules/cabana ./modules/signpost ./modules/pact</automated>
<fails_when>non-zero exit, or a "FAIL" line in the output</fails_when>
</verify>
<acceptance_criteria>
- `go test -short ./modules/cabana -run TestFormExtensionBootErrors -v | grep -c -- "--- PASS"` is at least 15
- `go test -short ./modules/cabana -run TestFormExtensionPlacement -v | grep -c -- "--- PASS"` is at least 11
- `go vet ./... && go test -short ./...` exits 0 in the framework
</acceptance_criteria>
<done>The framework seams carry exhaustive unit coverage for placement, boot errors, event diffs and descriptors.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Plugin unit tests (sm-seo-plugin classes, element backstops, Journal) and the completed VALIDATION.md</name>
<files>../sm-seo-plugin/classes/resolver_test.go, ../sm-seo-plugin/classes/redirect_graph_test.go, ../sm-seo-plugin/classes/normalize_test.go, ../sm-seo-plugin/classes/robots_value_test.go, ../sm-seo-plugin/classes/robots_test.go, ../sm-seo-plugin/classes/templates_test.go, ../sm-seo-plugin/classes/csv_test.go, ../sm-seo-plugin/classes/owner_test.go, ../sm-seo-plugin/classes/issues_test.go, ../sm-seo-plugin/tests/elements/text.test.ts, ../sm-seo-plugin/tests/elements/robots.test.ts, ../sm-seo-plugin/tests/elements/serp.test.ts, ../sm-seo-plugin/tests/elements/robotstxt.test.ts, ../sm-seo-plugin/tests/elements/transfer.test.ts, ../sm-journal-plugin/content_descriptors_test.go, ../sm-journal-plugin/menu_item_types_test.go, .planning/phases/32-seo-foundation/32-VALIDATION.md</files>
<read_first>
- .planning/phases/32-seo-foundation/32-VALIDATION.md and 32-UI-SPEC.md (UI Considerations backstop rows, C4, C5, C6, C10, C13)
- ../sm-seo-plugin/classes/*.go and assets/src/elements/*.ts
- ../sm-journal-plugin/tests/ (vitest setup precedent) and ../sm-seo-plugin/vite.config.ts
- Google's robots.txt documentation examples cited in 32-RESEARCH.md (longest match, allow wins ties, $ anchor, * wildcard)
</read_first>
<behavior>
- TestResolverPrecedence (full matrix): every value chain of plan 32-03 P-14 with each layer present and absent, sources asserted; staging lock; draft not found
- TestRedirectGraph: flatten A→B,B→C ⇒ A→C; back and forth A→B→A→C ⇒ A→C and B→C; loop refusal for exact, pattern probe and regex probe; chain notice; 10-hop runtime stop
- TestNormalizeMatrix, TestRobotsValueRoundTrip (every directive combination; malformed refused), TestRobotsMatcherGoogleExamples, TestTemplates (unknown placeholder refused), TestCSV (BOM, delimiters, quoting, formula prefix), TestOwnerResolution (record, rule, force_rule, locale prefix), TestIssues (each issue kind)
- Element backstops: text.test.ts (thresholds 29/30 chars and 580/581 px with mocked measureText for both measures; 320px wrap without overlap), robots.test.ts (grammar round-trip; all-inherit empty; 360px stacking with en and pl labels unclipped), serp.test.ts (effective title through type template then general template; word-boundary truncation within 580px; stem matching grzyby/grzybów yes, grzyb/grzbiet no, diacritics and case ignored, hyphens equal spaces), robotstxt.test.ts (each lint rule on its sample line; clean file has none), transfer.test.ts (one refused row disables Apply and shows refusedBlocks; zero changes shows nothing)
- Journal: descriptor matrix for post, category, tag (Describe per locale, Match exact/non-exact, Records paging), journal-tag type with tag_pages on and off, translated slug lookup
</behavior>
<action>
Write the unit tests per the behaviour list (SC-2, SC-3, SC-4, D-09, D-22, D-25 and the UI-SPEC backstops); pure Go tests run under `-short`. Element tests run with `npm --prefix ../sm-seo-plugin run test` (vitest + happy-dom, `tests/**/*.test.ts`). Fix defects in their own commits. Then update `32-VALIDATION.md`: the per-task verification map (every task of plans 32-01 to 32-06 with its automated command and status), Wave 0 items checked, `nyquist_compliant: true`, `wave_0_complete: true`, `status: validated` only if the full gate is green.
Commits (resume units, see Execution units), staged by path, in this order: A in ../sm-seo-plugin `test: unit tests for the SEO classes` (the nine `classes/*_test.go` files of this task); B in ../sm-seo-plugin `test: element backstops` (the five `tests/elements/*.test.ts` files); C in ../sm-journal-plugin `test: content descriptors, journal-tag type and translated slugs` (content_descriptors_test.go, menu_item_types_test.go); D in this repository, as a planning-docs commit, `docs(32-06): complete the validation map` (32-VALIDATION.md only).
</action>
<verify>
<automated>go -C ../sm-seo-plugin test -short ./classes -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -40</automated>
<fails_when>a "FAIL" line, "no tests to run", or TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution or TestIssues missing from the "--- PASS" lines</fails_when>
<automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -15</automated>
<fails_when>non-zero exit, a failed, skipped or todo test, or a missing passing line for any of text, robots, serp, robotstxt, transfer test files</fails_when>
<automated>go -C ../sm-journal-plugin test -count=1 ./... -run 'TestContentDescriptors|TestJournalTagType|TestTranslatedSlug|TestAPIRecordEvents' -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20</automated>
<fails_when>non-zero exit, a "FAIL" line, or any of the four names missing from the "--- PASS" lines</fails_when>
<automated>grep -c "nyquist_compliant: true" .planning/phases/32-seo-foundation/32-VALIDATION.md</automated>
<fails_when>the count is 0</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "grzybów" ../sm-seo-plugin/tests/elements/serp.test.ts` prints at least one line
- `grep -c "it(\|test(" ../sm-seo-plugin/tests/elements/*.test.ts` totals at least 20
- `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -count=1 ./...` exits 0 with Docker
- 32-VALIDATION.md lists every plan task with an automated command
</acceptance_criteria>
<done>The plugin, its elements and Journal's new code are unit-tested per the validation map, and VALIDATION.md is complete.</done>
</task>
<task type="checkpoint:decision" gate="blocking">
<name>Task 4: Release go-ahead: confirm the framework patch version, the plugin versions and the push list</name>
<reversibility rating="one-way">A pushed tag is pinned by plugin go.mod files and application submodules; Phase 33 builds on it, and the seams become published API.</reversibility>
<decision>Release Phase 32 now with the computed versions, and which remote operations are approved?</decision>
<context>Before asking, the executor runs and reports: `git fetch --tags origin` in the framework, sm-journal-plugin and sm-seo-plugin; `git tag --sort=-v:refname | head -3` in the framework; the next patch = the newest framework tag with its patch number plus one (D-106: v0.5.x today; a later 0.5.x if another session tagged meanwhile; only the third number moves); Journal's next patch from its newest tag; sm-seo-plugin's first version following the sibling plugins' first-release convention; `bash scripts/check-phase32.sh --all` with `PHASE32_ALLOW_UNMOUNTED=1` (green except the mount, which Task 5 completes); clean trees in the three repositories apart from other sessions' known uncommitted demo entries (listed). The push list it presents: framework master and the tag; sm-journal-plugin main and its tag; sm-seo-plugin main (first push) and its tag; then local demo commits (apply the 32-05 patch, add the seo submodule, bump Journal, require the framework tag), pushed only if named; the summercms.io changelog post for the framework tag; optional Journal bumps in sm-grzybyfunkcjonalne-app (with `golem15.journal.tag_pages: false`, its layer has no tag pages) and sm-summercmsio-app. Nothing is deployed in any option.</context>
<options>
<option id="release">
<name>Release with the computed versions (Jakub names or corrects each number in the reply)</name>
<pros>Phase 33 can build on a tagged framework (Q-01); the demo mounts the plugin; SC-5 closes</pros>
<cons>One-way: tags and the seams' API are published</cons>
</option>
<option id="release-no-apps">
<name>Release framework and plugins; keep every application change local (no app pushes, no optional app bumps)</name>
<pros>No application repository leaves this machine</pros>
<cons>The demo stays unpushed until a later go-ahead</cons>
</option>
<option id="hold">
<name>Hold: no tag, no push</name>
<pros>Lets Jakub review the seams' API first</pros>
<cons>SC-5 stays open; the demo cannot mount the plugin (TestGoModRequiresTags); recorded in deferred-items.md</cons>
</option>
</options>
<resume-signal>Answer release, release-no-apps or hold, with the confirmed version numbers</resume-signal>
</task>
<task type="auto">
<name>Task 5: Tag, push and pin per the go-ahead; mount the plugin in the demo; full gate; changelog post</name>
<precondition>Task 4 answered release or release-no-apps with confirmed version numbers; on hold, only write deferred-items.md and stop</precondition>
<files>../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/README.md, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-demo-app/summer.yaml, ../sm-demo-app/go.work, ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, ../sm-demo-app/.gitmodules, ../sm-demo-app/plugins.gen.go, ../sm-demo-app/main.go, ../sm-demo-app/config/golem15.seo.yaml, ../sm-demo-app/deploy/nginx/demo.summercms.io.conf, ../sm-demo-app/DEPLOY.md, ../sm-demo-app/plugins/golem15/seo, ../sm-demo-app/plugins/golem15/journal, .planning/phases/32-seo-foundation/deferred-items.md, .planning/phases/32-seo-foundation/32-06-release-log.md</files>
<read_first>
- The Task 4 answer
- ../CLAUDE.md and CLAUDE.md (commits, concurrency, release and changelog rules)
- ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog")
- .planning/phases/32-seo-foundation/32-05-demo-mount.patch
- .planning/phases/19.7-generic-mcp-module/19.7-04-PLAN.md (release task: pre-checks, annotated tags, pin commits)
</read_first>
<action>
In this order, with the exact versions from Task 4 (SC-5, Q-01, D-106):
0. Release log (the resume record of this one-way task): before step 1 create `.planning/phases/32-seo-foundation/32-06-release-log.md` with the Task 4 answer and the confirmed versions; after each step below append `step <n> done: <what> <tag, SHA or URL>`. A resumed run reads the log, confirms each step marked done (`git ls-remote --tags origin <tag>` prints the tag; the demo commit subject exists; the changelog URL answers) and skips it: a tag is never created twice, a push is never forced, the changelog is never posted twice. The log is committed with 32-06-SUMMARY.md as a planning-docs commit.
1. Framework: if `admin/` changed since the last tag, `npm --prefix admin run build`, commit `modules/boardwalk/dist`, `scripts/check-admin-dist.sh` green; annotated tag `git tag -a <framework version>` with a message summarising the three seams in user-facing words; `git push origin master` and the tag.
2. sm-journal-plugin: require the framework tag in go.mod (`GOWORK=off go get git.golem15.com/golem15/summercms@<tag>` with GOPRIVATE set as the demo's check-pins.sh does), `GOWORK=off go build ./... && GOWORK=off go test -short ./...`, commit `chore: require SummerCMS <tag>`, annotated tag, push main and the tag.
3. sm-seo-plugin: the same require, README dependency line, commit, annotated tag, `git push -u origin main` and the tag.
4. sm-demo-app (only paths from this plan; never other sessions' uncommitted entries): first commit `chore: require SummerCMS <tag>` (go.mod, go.sum); then the Journal bump: move `plugins/golem15/journal` to the Journal tag only when its current HEAD is an ancestor of that tag (else stop and ask: the submodule carries foreign work), require the Journal tag, commit `chore: bump journal to <version>` (go.mod, go.sum, the submodule pointer); then the mount: `git apply` the 32-05 patch, `git submodule add -b main git@git.golem15.com:golem15/sm-seo-plugin.git plugins/golem15/seo` checked out at its tag, `use ./plugins/golem15/seo` in go.work, require the sm-seo-plugin tag, `go run ../summercms.go/cmd/summer build` to regenerate plugins.gen.go and main.go, then `go build ./... && go build -tags summercms_mcp ./... && go test -short ./... && go test -short -tags summercms_mcp -run 'TestSEOToolsCatalog|TestGoModRequiresTags|TestSEOTabMounted' .` and `scripts/check-pins.sh`; commit `feat: mount golem15.seo <version>`. Push the demo only if the answer named it. Optional app bumps only if named, one commit per app.
5. `bash scripts/check-phase32.sh --all` (mounted, Docker) must be green; record the output summary.
6. Changelog (same day, from `../sm-summercmsio-app`): `scripts/changelog-post.py source <tag>`, write user-facing notes with `## Highlights`, `## Added`, `## Changed`, `## Fixed` (no planning IDs, no quick-task codes, no H1) to a scratch file, `scripts/changelog-post.py publish <tag> <notes> --excerpt "..."`. Never print or copy the token; if the token file is missing or refused, stop and ask Jakub.
7. Anything not done (hold, refused push, unnamed apps) goes to `.planning/phases/32-seo-foundation/deferred-items.md` with the exact next command; STATE.md updates go through `msd_run query state.*` handlers only, and a short g15office comment on the Phase 32 task records the release (`.planning/G15OFFICE.md`).
</action>
<verify>
<automated>git describe --tags --exact-match HEAD 2>/dev/null || git tag --points-at HEAD</automated>
<fails_when>empty output (no framework tag on the release commit) when Task 4 answered release or release-no-apps</fails_when>
<automated>bash scripts/check-phase32.sh --all</automated>
<fails_when>non-zero exit, a "FAIL", "refuse:", "NOT MOUNTED", "additive: removed or changed" or "additive: method added to existing interface" line in the output</fails_when>
<automated>go -C ../sm-demo-app test -short -tags summercms_mcp -run 'TestGoModRequiresTags|TestSEOTabMounted|TestSEOToolsCatalog' -count=1 -v . 2>&1 | grep -E '^(--- |FAIL|ok)'</automated>
<fails_when>a "FAIL" or "SKIP" line, or any of the three names missing from the "--- PASS" lines</fails_when>
</verify>
<acceptance_criteria>
- `git ls-remote --tags origin <framework tag>` prints one line, and the tag keeps the major and middle numbers of the previous newest tag with the last number one higher (a patch release)
- `grep -n "git.golem15.com/golem15/summercms <framework tag>" ../sm-seo-plugin/go.mod ../sm-journal-plugin/go.mod` prints a line in each
- `grep -n "golem15.seo" ../sm-demo-app/summer.yaml` prints one line and `bash ../sm-demo-app/scripts/check-pins.sh` prints `check-pins: ok`
- The changelog post URL for the tag is recorded in 32-06-SUMMARY.md
- Every demo commit of this task lists only this plan's paths (`git show --stat`)
- `32-06-release-log.md` has one `step <n> done` line per performed step and no step twice
</acceptance_criteria>
<done>The framework patch release carrying the three seams is tagged and announced, both plugins are released on it, the demo mounts the SEO plugin, and the full phase gate is green.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| local repositories → git.golem15.com | tags and branches published to consumers |
| release script → summercms.io changelog | authenticated post with a scoped token |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-32-30 | Tampering | published framework API (patch tag) | high | mitigate | additive stage compares the API surface (scripts/apisurface) of pact, cabana, signpost and every changed framework package with the last tag and refuses removed or changed elements and methods added to existing interfaces, with a self-test that plants each case; compat stage tests three consumers and builds every other sibling that requires the framework (D-106) |
| T-32-31 | Repudiation | pushes and tags without consent | high | mitigate | Task 4 blocking decision checkpoint; Task 5 performs exactly the named operations and records each in the SUMMARY |
| T-32-32 | Information disclosure | changelog token | high | mitigate | changelog-post.py reads ~/.config/summercms/journal-token itself; the executor never prints, copies or mints it and asks Jakub on a missing or refused token |
| T-32-33 | Tampering | demo submodules carrying foreign work | medium | mitigate | the Journal submodule moves only when its HEAD is an ancestor of the release tag; other sessions' uncommitted entries are never staged |
| T-32-34 | Tampering | a resumed release task repeating one-way steps | medium | mitigate | 32-06-release-log.md records each step as it completes; a resumed run confirms and skips logged steps, never re-tags, force-pushes or re-posts |
| T-32-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new package; tests use the toolchain approved at plan 32-03 Task 4; scripts/apisurface uses the standard library only |
</threat_model>
<verification>
- `bash scripts/check-phase32.sh --all` green with Docker and the demo mounted (or the hold recorded in deferred-items.md).
- `go vet ./... && go test ./...` green in the framework, sm-seo-plugin, sm-journal-plugin and sm-demo-app; `npm --prefix ../sm-seo-plugin run test` green.
- 32-VALIDATION.md `nyquist_compliant: true`.
- Decision coverage: `comm -23 <(grep -oE '^- \*\*D-[0-9]+[abc]?' .planning/phases/32-seo-foundation/32-CONTEXT.md | sed 's/^- \*\*//' | LC_ALL=C sort -u) <(grep -oE '^\| D-[0-9]+[abc]?' .planning/phases/32-seo-foundation/32-DECISIONS-MAP.md | sed 's/^| //' | LC_ALL=C sort -u)` prints nothing: every decision is mapped to a plan or to Phase 33.
</verification>
<success_criteria>
SC-5: the framework release with the seams is tagged as the next v0.5.x patch, it is strictly additive (D-106: no removed or changed API element, no method added to an existing interface), and the new code has unit tests delivered in this last plan; SC-1 to SC-4 are proven by the gate and the demo.
</success_criteria>
## Artifacts this phase produces (plan 06)
- `scripts/check-phase32.sh` stages `additive` and `compat`; `scripts/apisurface/main.go` (the API surface printer the additive stage compares)
- Unit tests: `modules/cabana/form_extensions_test.go`, `modules/cabana/record_events_test.go`, `modules/signpost/content_test.go`; `../sm-seo-plugin/classes/*_test.go` (TestResolverPrecedence, TestRedirectGraph, TestNormalizeMatrix, TestRobotsValueRoundTrip, TestRobotsMatcherGoogleExamples, TestTemplates, TestCSV, TestOwnerResolution, TestIssues); `../sm-seo-plugin/tests/elements/{text,robots,serp,robotstxt,transfer}.test.ts`; Journal descriptor, tag-type and slug tests
- Updated `32-VALIDATION.md`; `deferred-items.md` when anything is held; `32-06-release-log.md` (the release steps as performed)
- Tags (after the go-ahead): the framework's next v0.5.x patch, sm-journal-plugin's next patch, sm-seo-plugin's first release
- sm-demo-app commits: framework pin, Journal bump, golem15.seo mount (summer.yaml, go.work, go.mod, .gitmodules, plugins.gen.go, main.go, config/golem15.seo.yaml, nginx `location = /robots.txt`, DEPLOY.md)
- The summercms.io changelog post for the framework tag
<output>
Create `.planning/phases/32-seo-foundation/32-06-SUMMARY.md` when done with the Task 4 answer, every version, tag, push and commit SHA (per task and commit group, the release steps copied from 32-06-release-log.md), the gate output summary and the changelog URL.
</output>

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.

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